Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 82 additions & 20 deletions openHAB/UI/SwiftUI/Rows/ColorTemperaturePickerRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,69 @@ import os.log
import SFSafeSymbols
import SwiftUI

enum ColorTemperatureRowMath {
static let minimumKelvin = 1_000.0
static let maximumKelvin = 10_000.0
static let warmWhiteKelvin = 2_700.0
static let minimumGradientSteps = 1
static let minimumSliderStep = 1.0
static let minimumValidSpan = 0.0
static let minimumConvertibleKelvin = 0.0
static let fractionLowerBound = 0.0
static let fractionUpperBound = 1.0

static let fallbackRange: ClosedRange<Double> = minimumKelvin ... maximumKelvin
static let fallbackTemperature = warmWhiteKelvin

static func normalizedRange(minValue: Double, maxValue: Double) -> ClosedRange<Double> {
let lowerCandidate = sanitizeTemperatureValue(minValue) ?? fallbackRange.lowerBound
let upperCandidate = sanitizeTemperatureValue(maxValue) ?? fallbackRange.upperBound

let lower = min(lowerCandidate, upperCandidate).clamped(to: fallbackRange)
let upper = max(lowerCandidate, upperCandidate).clamped(to: fallbackRange)

guard lower.isFinite, upper.isFinite, lower < upper else {
return fallbackRange
}

return lower ... upper
}

static func normalizedTemperature(state: String?, serverValue: Double?, range: ClosedRange<Double>) -> Double {
let resolvedValue = state.flatMap { parseTemperature(state: $0) }
?? sanitizeTemperatureValue(serverValue)
?? fallbackTemperature
return resolvedValue.clamped(to: range)
}

static func gradientTemperatures(for range: ClosedRange<Double>, steps: Int) -> [Double] {
let safeSteps = max(steps, minimumGradientSteps)
let span = range.upperBound - range.lowerBound
return (0 ... safeSteps).map { index in
range.lowerBound + (span * Double(index) / Double(safeSteps))
}
}

static func sliderFraction(value: Double, range: ClosedRange<Double>) -> Double {
let span = range.upperBound - range.lowerBound
guard span.isFinite, span > minimumValidSpan else { return fractionLowerBound }

let fraction = (value - range.lowerBound) / span
return fraction.clamped(to: fractionLowerBound ... fractionUpperBound)
}

static func sanitizeTemperatureValue(_ value: Double?) -> Double? {
guard let value, value.isFinite, value > minimumConvertibleKelvin else { return nil }
let convertedValue = value < fallbackRange.lowerBound ? value.asColorTemperatureInKelvin : value
return convertedValue.isFinite ? convertedValue : nil
}

private static func parseTemperature(state: String) -> Double? {
guard !state.isEmpty else { return nil }
return sanitizeTemperatureValue(state.parseAsNumber().value)
}
}

private struct ColorTemperatureRowConfig {
let input: ColorTemperatureRowInput
let viewModel: SitemapPageViewModel
Expand Down Expand Up @@ -53,7 +116,7 @@ struct CustomSliderView: View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
let normalized = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound))
let normalized = CGFloat(ColorTemperatureRowMath.sliderFraction(value: value, range: range))
let xPos = normalized * width

ZStack(alignment: .leading) {
Expand All @@ -72,9 +135,11 @@ struct CustomSliderView: View {
isDragging = true
onDragStateChanged(true)
}
guard width > ColorTemperatureRowMath.minimumValidSpan else { return }
let location = gesture.location.x.clamped(to: 0 ... width)
let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound
let stepped = (raw / step).rounded() * step
let safeStep = max(step, ColorTemperatureRowMath.minimumSliderStep)
let stepped = (raw / safeStep).rounded() * safeStep
value = stepped.clamped(to: range)
let now = Date()
if now.timeIntervalSince(lastSendTime) > 0.2 {
Expand Down Expand Up @@ -105,13 +170,8 @@ private struct ColorTemperaturePickerRowContent: View {

private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView")

// Use widget's min/max values, similar to Android implementation
private var minTemperature: Double {
input.clampedMinTemperature
}

private var maxTemperature: Double {
input.clampedMaxTemperature
private var temperatureRange: ClosedRange<Double> {
ColorTemperatureRowMath.normalizedRange(minValue: input.minValue, maxValue: input.maxValue)
}

var body: some View {
Expand Down Expand Up @@ -167,7 +227,7 @@ private struct ColorTemperaturePickerRowContent: View {
// Actual slider
CustomSliderView(
value: $selectedTemperature,
range: minTemperature ... maxTemperature,
range: temperatureRange,
step: 100,
onEditingChanged: {
sendTemperatureCommand()
Expand All @@ -188,15 +248,15 @@ private struct ColorTemperaturePickerRowContent: View {
}
}
.onAppear {
selectedTemperature = loadCurrentTemperature(state: displayState.effectiveState) ?? input.serverValue ?? 2700
selectedTemperature = loadCurrentTemperature(state: displayState.effectiveState)
}
.onChange(of: displayState.effectiveState) { newState in
guard !isDraggingSlider else { return }
if suppressNextServerSync {
suppressNextServerSync = false
return
}
selectedTemperature = loadCurrentTemperature(state: newState) ?? input.serverValue ?? 2700
selectedTemperature = loadCurrentTemperature(state: newState)
}
.onDisappear {
onCancelPending(input.colorTemperatureCommandKey)
Expand All @@ -218,20 +278,22 @@ private struct ColorTemperaturePickerRowContent: View {

// Generate gradient colors similar to Android implementation
private func colorTemperatureGradient(steps: Int = 20) -> [Color] {
stride(from: minTemperature, through: maxTemperature, by: (maxTemperature - minTemperature) / Double(steps)).map { Color(temperature: $0) }
ColorTemperatureRowMath.gradientTemperatures(for: temperatureRange, steps: steps).map { Color(temperature: $0) }
}

private func loadCurrentTemperature(state: String?) -> Double? {
guard let state, !state.isEmpty else { return nil }

// Parse color temperature directly from Kelvin value (like Android app)
let kelvin = state.parseAsNumber().value
return kelvin.clamped(to: minTemperature ... maxTemperature)
private func loadCurrentTemperature(state: String?) -> Double {
ColorTemperatureRowMath.normalizedTemperature(
state: state,
serverValue: input.serverValue,
range: temperatureRange
)
}

private func sendTemperatureCommand() {
// Send temperature directly as Kelvin value (like Android app)
let clampedRoundedTemperature = Int(selectedTemperature.rounded()).clamped(to: Int(minTemperature) ... Int(maxTemperature))
let clampedRoundedTemperature = Int(selectedTemperature.rounded()).clamped(
to: Int(temperatureRange.lowerBound) ... Int(temperatureRange.upperBound)
)
let command = "\(clampedRoundedTemperature)"

logger.info("Sending color temperature command: \(command)K")
Expand Down
115 changes: 115 additions & 0 deletions openHABTestsSwift/ColorTemperatureRowMathTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2010-2026 Contributors to the openHAB project
//
// See the NOTICE file(s) distributed with this work for additional
// information.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0
//
// SPDX-License-Identifier: EPL-2.0

@testable import openHAB

import Testing

private enum TestValues {
static let oversizedKelvin = 15_000.0
static let miredWarmest = 370.0
static let miredCoolest = 153.0
static let convertedWarmestKelvin = 2702.7027027027025
static let convertedCoolestKelvin = 6535.947712418301
static let miredState = "250"
static let convertedStateKelvin = 4_000.0
static let invalidState = "not-a-number"
static let clampedServerValue = 6_500.0
static let oversizedServerValue = 9_000.0
static let minimumRangeKelvin = 1_000.0
static let narrowMaximumKelvin = 6_500.0
static let degenerateRangeKelvin = 4_000.0
static let gradientStartKelvin = 2_000.0
static let gradientEndKelvin = 3_000.0
static let gradientSteps = 4
static let gradientCount = 5
static let tolerance = 0.0001
static let zero = 0.0
}

@Suite
struct ColorTemperatureRowMathTests {
@Test
func normalizedRangeUsesFallbackForZeroSpan() {
let range = ColorTemperatureRowMath.normalizedRange(
minValue: TestValues.oversizedKelvin,
maxValue: TestValues.oversizedKelvin
)

#expect(range.lowerBound == ColorTemperatureRowMath.minimumKelvin)
#expect(range.upperBound == ColorTemperatureRowMath.maximumKelvin)
}

@Test
func normalizedRangeConvertsMiredBoundsToKelvin() {
let range = ColorTemperatureRowMath.normalizedRange(
minValue: TestValues.miredWarmest,
maxValue: TestValues.miredCoolest
)

#expect(abs(range.lowerBound - TestValues.convertedWarmestKelvin) < TestValues.tolerance)
#expect(abs(range.upperBound - TestValues.convertedCoolestKelvin) < TestValues.tolerance)
}

@Test
func normalizedTemperatureConvertsMiredStateAndClampsIntoRange() {
let range = ColorTemperatureRowMath.normalizedRange(
minValue: TestValues.miredWarmest,
maxValue: TestValues.miredCoolest
)

let temperature = ColorTemperatureRowMath.normalizedTemperature(
state: TestValues.miredState,
serverValue: nil,
range: range
)

#expect(temperature == TestValues.convertedStateKelvin)
}

@Test
func normalizedTemperatureFallsBackToClampedServerValue() {
let range = ColorTemperatureRowMath.normalizedRange(
minValue: TestValues.minimumRangeKelvin,
maxValue: TestValues.narrowMaximumKelvin
)

let temperature = ColorTemperatureRowMath.normalizedTemperature(
state: TestValues.invalidState,
serverValue: TestValues.oversizedServerValue,
range: range
)

#expect(temperature == TestValues.clampedServerValue)
}

@Test
func sliderFractionReturnsZeroForDegenerateRange() {
let fraction = ColorTemperatureRowMath.sliderFraction(
value: TestValues.gradientEndKelvin,
range: TestValues.degenerateRangeKelvin ... TestValues.degenerateRangeKelvin
)

#expect(fraction == TestValues.zero)
}

@Test
func gradientTemperaturesAlwaysIncludeBothBounds() {
let temperatures = ColorTemperatureRowMath.gradientTemperatures(
for: TestValues.gradientStartKelvin ... TestValues.gradientEndKelvin,
steps: TestValues.gradientSteps
)

#expect(temperatures.count == TestValues.gradientCount)
#expect(temperatures.first == TestValues.gradientStartKelvin)
#expect(temperatures.last == TestValues.gradientEndKelvin)
}
}
Loading