Skip to content

Commit

Permalink
Correct color conversions
Browse files Browse the repository at this point in the history
Kelvin wasn't right and added mireds
  • Loading branch information
EAGrahamJr committed Jun 14, 2024
1 parent da36cc0 commit dff7b2a
Show file tree
Hide file tree
Showing 7 changed files with 85 additions and 46 deletions.
2 changes: 1 addition & 1 deletion src/main/kotlin/crackers/kobots/mqtt/KobotsMQTT.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ class KobotsMQTT(private val clientName: String, broker: String) : AutoCloseable
*
* TODO should this be public?
*/
internal fun addTopicListener(topic: String, listener: (String, ByteArray) -> Unit) {
fun addTopicListener(topic: String, listener: (String, ByteArray) -> Unit) {
val sub = MqttSubscription(topic, 0)
// STUPID FUCKING BUG!!!!
val props = MqttProperties().apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package crackers.kobots.mqtt.homeassistant

import crackers.kobots.mqtt.homeassistant.LightColor.Companion.toLightColor
import crackers.kobots.mqtt.homeassistant.LightCommand.Companion.commandFrom
import crackers.kobots.parts.kelvinToRGB
import crackers.kobots.parts.miredsToColor
import crackers.kobots.parts.toMireds
import org.json.JSONObject
import java.awt.Color
import java.awt.Color.BLACK
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.roundToInt
Expand Down Expand Up @@ -46,15 +48,18 @@ data class LightColor(
data class LightState(
val state: Boolean = false,
val brightness: Int = 0,
val color: LightColor = Color.BLACK.toLightColor(),
val color: Color = BLACK,
val effect: String? = null
) {

fun json(): JSONObject {
return JSONObject(this).apply {
return JSONObject().apply {
put("color", JSONObject(color.toLightColor()))
put("state", if (state) "ON" else "OFF")
put("brightness", (brightness * 255f / 100f).roundToInt())
put("color_mode", LightColorMode.RGB.name.lowercase())
put("color_temp", color.toMireds())
effect?.let { e -> put("effect", e) }
}
}
}
Expand Down Expand Up @@ -84,11 +89,11 @@ data class LightCommand(
val brightness = takeIf { has("brightness") }?.let { getInt("brightness") * 100f / 255f }?.roundToInt()

// this is in mireds, so we need to convert to Kelvin
val colorTemp = takeIf { has("color_temp") }?.let { 1000000f / getInt("color_temp") }?.roundToInt()
val colorTemp = takeIf { has("color_temp") }?.let { getInt("color_temp").miredsToColor() }

val color = optJSONObject("color")?.let {
Color(it.optInt("r"), it.optInt("g"), it.optInt("b"))
} ?: colorTemp?.kelvinToRGB()
} ?: colorTemp

// set state regardless
if (brightness != null || color != null) state = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package crackers.kobots.mqtt.homeassistant

import crackers.kobots.devices.lighting.PimoroniLEDShim
import crackers.kobots.mqtt.homeassistant.LightColor.Companion.toLightColor
import crackers.kobots.parts.scale
import java.awt.Color
import java.util.concurrent.CompletableFuture
Expand Down Expand Up @@ -55,7 +54,7 @@ class PimoroniShimController(private val device: PimoroniLEDShim) : LightControl
override fun current(): LightState = LightState(
state = currentState.get(),
brightness = currentBrightness.get(),
color = currentColor.get().toLightColor(),
color = currentColor.get(),
effect = currentEffect.get()
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package crackers.kobots.mqtt.homeassistant

import crackers.kobots.devices.lighting.PixelBuf
import crackers.kobots.devices.lighting.WS2811
import crackers.kobots.mqtt.homeassistant.LightColor.Companion.toLightColor
import org.slf4j.LoggerFactory
import java.awt.Color
import java.util.concurrent.CompletableFuture
Expand All @@ -12,6 +11,7 @@ import kotlin.math.roundToInt
/**
* Simple HomeAssistant "light" that controls a single pixel on a 1->n `PixelBuf` strand (e.g. a WS28xx LED). Note
* that the effects **must** be in the context of a `PixelBuf` target.
* TODO this should be just a PixelBufController with the start==end index
*/
class SinglePixelLightController(
private val theStrand: PixelBuf,
Expand All @@ -32,7 +32,7 @@ class SinglePixelLightController(
return LightState(
state = state,
brightness = if (!state) 0 else (lastColor.brightness!! * 100f).roundToInt(),
color = lastColor.color.toLightColor(),
color = lastColor.color,
effect = currentEffect.get()
)
}
Expand Down Expand Up @@ -66,6 +66,7 @@ class SinglePixelLightController(

/**
* Controls a full "strand" of `PixelBuf` (e.g. WS28xx LEDs)
* TODO needs a start and end index
*/
class PixelBufController(
private val theStrand: PixelBuf,
Expand Down Expand Up @@ -101,11 +102,11 @@ class PixelBufController(
}

override fun current(): LightState {
val state = theStrand.get().find { it.color != Color.BLACK }?.let { true } ?: false
val state = theStrand.get().any { it.color != Color.BLACK }
return LightState(
state = state,
brightness = if (!state) 0 else (lastColor.brightness!! * 100f).roundToInt(),
color = lastColor.color.toLightColor(),
color = lastColor.color,
effect = currentEffect.get()
)
}
Expand Down
48 changes: 40 additions & 8 deletions src/main/kotlin/crackers/kobots/parts/Graphics.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package crackers.kobots.parts

import java.awt.Color
import java.awt.Font
import java.awt.Graphics2D
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt

/**
* Note this only extracts the HSB _hue_ component of the color.
Expand Down Expand Up @@ -46,9 +49,10 @@ fun Double.colorLimit() = this.roundToInt().colorLimit()
fun Int.colorLimit() = this.coerceIn(0, 255)

/**
* Convert a temperature in Kelvin to an RGB color.
* Convert a temperature in Kelvin to an RGB color. **THIS IS NOT ACCURATE!!!**
*
* This is based on the algorithm from https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html
* Note that round-tripping between this and the inverse function does not work.
*/
fun Int.kelvinToRGB(): Color {
var red: Double
Expand Down Expand Up @@ -81,22 +85,50 @@ fun Int.kelvinToRGB(): Color {
/**
* Convert a color to a Kelvin temperature. **THIS IS NOT ACCURATE!!!**
*
* This is based on the algorithm from https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html
* Note that round-tripping between this and the inverse function does not work.
*/
fun Color.toKelvin(): Int {
// Convert RGB to XYZ using the sRGB color space
val r = red / 255.0
val g = green / 255.0
val b = blue / 255.0

val x = 0.4124 * r + 0.3576 * g + 0.1805 * b
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
val z = 0.0193 * r + 0.1192 * g + 0.9505 * b
val rLinear = if (r > 0.04045) ((r + 0.055) / (1.055)).pow(2.4) else r / 12.92
val gLinear = if (g > 0.04045) ((g + 0.055) / (1.055)).pow(2.4) else g / 12.92
val bLinear = if (b > 0.04045) ((b + 0.055) / (1.055)).pow(2.4) else b / 12.92

val x = rLinear * 0.4124 + gLinear * 0.3576 + bLinear * 0.1805
val y = rLinear * 0.2126 + gLinear * 0.7152 + bLinear * 0.0722
val z = rLinear * 0.0193 + gLinear * 0.1192 + bLinear * 0.9505

// Convert XYZ to CCT
val n = (x - 0.3320) / (0.1858 - y)
val cct = 449.0 * n.pow(3) + 3525.0 * n.pow(2) + 6823.3 * n + 5520.33

val n = (x - 0.3320) / (0.1858 - y + 0.3320)
val cct = 449.0 * n.pow(3.0) + 3525.0 * n.pow(2.0) + 6823.3 * n + 5520.33
return cct.roundToInt()
}

fun Int.squared() = toDouble().pow(2).toInt()

/**
* Additional math to convert to _mired_ values.
*/
fun Color.toMireds(): Int = 1_000_000 / toKelvin()
fun Int.miredsToColor(): Color = (1_000_000 / this).kelvinToRGB()

fun Color.toLuminance() = (0.2126 * red + 0.7152 * green + 0.0722 * blue)
fun Color.toLuminancePerceived() = (0.299 * red + 0.587 * green + 0.114 * blue)
fun Color.toLuminancePerceived2() = sqrt(0.299 * red.squared() + 0.587 * green.squared() + 0.114 * blue.squared())

val PURPLE = Color(0xB4, 0, 0xFF)
val GOLDENROD = Color(255, 150, 0)
val ORANGISH = Color(255, 130, 0)
val ORANGISH = Color(255, 75, 0)

/**
* Fits a font to a specified number of pixels.
*/
fun Graphics2D.fitFont(f: Font, h: Int): Font {
val nextFont = f.deriveFont(f.size + .5f)
val fm = getFontMetrics(nextFont)
return if (fm.height > h) f else fitFont(nextFont, h)
}
48 changes: 25 additions & 23 deletions src/test/kotlin/crackers/kobots/mqtt/KobotLightTest.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package crackers.kobots.mqtt

import crackers.kobots.app.AppCommon.mqttClient
import crackers.kobots.mqtt.homeassistant.*
import crackers.kobots.mqtt.homeassistant.DeviceIdentifier
import crackers.kobots.mqtt.homeassistant.KobotLight
import crackers.kobots.mqtt.homeassistant.LightController
import crackers.kobots.mqtt.homeassistant.LightState
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
Expand All @@ -10,8 +13,7 @@ import io.mockk.mockk
import org.json.JSONArray
import org.json.JSONObject
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.Network
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy
import java.awt.Color
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

Expand All @@ -20,29 +22,29 @@ class KobotLightTest : FunSpec(testGuts())
private fun testGuts(): FunSpec.() -> Unit = {
lateinit var broker: GenericContainer<*>

beforeSpec {
broker = GenericContainer("eclipse-mosquitto").apply {
setCommand("mosquitto -c /mosquitto-no-auth.conf")
withLabel("name", "mosquitto")
withExposedPorts(1883)
withNetwork(Network.SHARED)
start()
println("Staring broker")
waitingFor(HostPortWaitStrategy().forPorts(1883))
}
val mappedPort = broker.getMappedPort(1883)
System.setProperty("mqtt.broker", "tcp://localhost:$mappedPort")
println("Broker running on port $mappedPort")
}

afterSpec {
println("Stopping broker")
broker.stop()
}
// beforeSpec {
// broker = GenericContainer("eclipse-mosquitto").apply {
// setCommand("mosquitto -c /mosquitto-no-auth.conf")
// withLabel("name", "mosquitto")
// withExposedPorts(1883)
// withNetwork(Network.SHARED)
// start()
// println("Staring broker")
// waitingFor(HostPortWaitStrategy().forPorts(1883))
// }
// val mappedPort = broker.getMappedPort(1883)
// System.setProperty("mqtt.broker", "tcp://localhost:$mappedPort")
// println("Broker running on port $mappedPort")
// }
//
// afterSpec {
// println("Stopping broker")
// broker.stop()
// }

xtest("Setup a single light and verify discovery message") {
val controller = mockk<LightController>()
val initialState = LightState(brightness = 255, color = LightColor(255, 255, 255))
val initialState = LightState(brightness = 255, color = Color.WHITE)
every { controller.current() } returns initialState

// listen for discovery messages
Expand Down
6 changes: 3 additions & 3 deletions version.properties
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#Generated by the Semver Plugin for Gradle
#Mon Apr 29 11:42:55 PDT 2024
#Tue May 21 12:31:05 PDT 2024
version.buildmeta=
version.major=0
version.minor=0
version.patch=17
version.patch=18
version.prerelease=
version.semver=0.0.17
version.semver=0.0.18

0 comments on commit dff7b2a

Please sign in to comment.