Skip to content

Commit

Permalink
Update HA entities and others
Browse files Browse the repository at this point in the history
Added a lookup table to the StepperRotator to see if that helps eliminate drift.
  • Loading branch information
EAGrahamJr committed Feb 26, 2024
1 parent f9861e3 commit 95ed622
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ abstract class AbstractKobotEntity(
}

/**
* Send the current state message for this device, if HA is avaliable.
* Send the current state message for this device, if HA is avaliable. This is a _public_ method so that it
* **can** be triggered externally, such as in the case of effects running.
*/
protected open fun sendCurrentState(state: String = currentState()) {
open fun sendCurrentState(state: String = currentState()) {
if (homeassistantAvailable) mqttClient[statusTopic] = state
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,29 +70,31 @@ data class LightState(
* ```
*/
data class LightCommand(
val state: Boolean?,
val state: Boolean,
val brightness: Int?,
val color: Color?,
val effect: String?
) {
companion object {
fun JSONObject.commandFrom(): LightCommand = with(this) {
val state = optString("state", null)?.let { it == "ON" }
var state = optString("state", null)?.let { it == "ON" } ?: false
val effect = optString("effect", null)

// brightness is 0-255, so translate to 0-100
val brightness = optInteger("brightness")?.let { it * 100f / 255f }?.roundToInt()
val brightness = takeIf { has("brightness") }?.let { getInt("brightness") * 100f / 255f }?.roundToInt()

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

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

// set state regardless
if (brightness != null || color != null) state = true

LightCommand(state, brightness, color, effect)
}

private fun JSONObject.optInteger(key: String) = optIntegerObject(key, null)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package crackers.kobots.mqtt.homeassistant
*
* There are quite a few UI hints that can be set on these objects; defaults are set in all cases.
*/
class KobotNumber(
class KobotNumberEntity(
val handler: NumberHandler,
uniqueId: String,
name: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.json.JSONObject
* A "select" entity allows for a device to react to explicit "commands". This conforms to the
* [MQTT Select](https://www.home-assistant.io/integrations/select.mqtt)
*/
class KobotSelect(
class KobotSelectEntity(
val selectHandler: SelectHandler,
uniqueId: String,
name: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package crackers.kobots.mqtt.homeassistant

import java.util.concurrent.atomic.AtomicReference

/**
* Handles simple text messages.
*/
class KobotTextEntity(
val textHandler: (String) -> Unit,
uniqueId: String,
name: String,
deviceIdentifier: DeviceIdentifier
) : CommandEntity(uniqueId, name, deviceIdentifier) {

override val component = "text"
override val icon = "mdi:text"

private val currentText = AtomicReference("")

override fun currentState() = currentText.get()

override fun handleCommand(payload: String) {
textHandler(payload)
currentText.set(payload)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,42 @@ 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
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.roundToInt

/**
* Simple HomeAssistant "light" that controls a single pixel on a 1->n PixelBuf strand.
* 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.
*/
class SinglePixelLightController(private val theStrand: PixelBuf, private val index: Int) : LightController {
class SinglePixelLightController(
private val theStrand: PixelBuf,
private val index: Int,
private val effects: Map<String, PixelBuf.(index: Int) -> Any>? = null
) : LightController {

private val logger = LoggerFactory.getLogger(this.javaClass.simpleName)

private var lastColor: WS2811.PixelColor = WS2811.PixelColor(Color.WHITE, brightness = 0.5f)
override val lightEffects: List<String>? = null
override val controllerIcon = "mdi:led-strip"
override val lightEffects = effects?.keys?.sorted()
override val controllerIcon = "mdi:lightbulb"
private val currentEffect = AtomicReference<String>()

// note: brightness for the kobot lights is 0-100
override fun current(): LightState {
val state = theStrand[index].color != Color.BLACK
return LightState(
state = state,
brightness = if (!state) 0 else (lastColor.brightness!! * 100f).roundToInt(),
color = lastColor.color.toLightColor()
color = lastColor.color.toLightColor(),
effect = currentEffect.get()
)
}

override fun set(command: LightCommand) {
if (command.state == false) {
if (!command.state) {
theStrand[index] = Color.BLACK
return
}
Expand All @@ -41,23 +53,70 @@ class SinglePixelLightController(private val theStrand: PixelBuf, private val in
theStrand[index] = lastColor
}

override fun exec(effect: String): CompletableFuture<Void> {
TODO("Not yet implemented")
override fun exec(effect: String) = CompletableFuture.runAsync {
try {
effects?.get(effect)?.invoke(theStrand, index)?.also { currentEffect.set(effect) }
} catch (t: Throwable) {
logger.error("Error executing effect $effect", t)
}
}.whenComplete { _, t ->
currentEffect.set(null)
}
}

class PixelBufController(private val theStrand: PixelBuf) : LightController {
override val lightEffects: List<String>? = null
/**
* Controls a full "strand" of `PixelBuf` (e.g. WS28xx LEDs)
*/
class PixelBufController(
private val theStrand: PixelBuf,
private val effects: Map<String, PixelBuf.() -> Any>? = null
) : LightController {
private val logger = LoggerFactory.getLogger(this.javaClass.simpleName)
override val lightEffects = effects?.keys?.sorted()
override val controllerIcon = "mdi:led-strip"

private var lastColor: WS2811.PixelColor = WS2811.PixelColor(Color.WHITE, brightness = 0.5f)
private val currentEffect = AtomicReference<String>()

/**
* Finds the first non-black color or the first pixel.
*/
private fun currentColor() = theStrand.get().find { it.color != Color.BLACK } ?: theStrand[0]

override fun set(command: LightCommand) {
TODO("Not yet implemented")
// just black it out, do not save this "color"
if (!command.state) {
theStrand fill Color.BLACK
return
}

// need to be able to "resume" the last color set if it wasn't turned off
val currentColor = currentColor().let { if (it.color == Color.BLACK) lastColor else it }
val color = command.color ?: currentColor.color
// if there's a brightness, otherwise "resume" again
val cb = command.brightness?.let { it / 100f } ?: currentColor.brightness
// save it, send it
lastColor = WS2811.PixelColor(color, brightness = cb)
theStrand fill lastColor
}

override fun current(): LightState {
TODO("Not yet implemented")
val state = theStrand.get().find { it.color != Color.BLACK }?.let { true } ?: false
return LightState(
state = state,
brightness = if (!state) 0 else (lastColor.brightness!! * 100f).roundToInt(),
color = lastColor.color.toLightColor(),
effect = currentEffect.get()
)
}

override fun exec(effect: String): CompletableFuture<Void> {
TODO("Not yet implemented")
override fun exec(effect: String) = CompletableFuture.runAsync {
try {
effects?.get(effect)?.invoke(theStrand)?.also { currentEffect.set(effect) }
} catch (t: Throwable) {
logger.error("Error executing effect $effect", t)
}
}.whenComplete { _, t ->
currentEffect.set(null)
}
}
6 changes: 6 additions & 0 deletions src/main/kotlin/crackers/kobots/parts/Graphics.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package crackers.kobots.parts

import java.awt.Color
import java.awt.Font
import java.awt.FontMetrics
import javax.imageio.ImageIO
import kotlin.math.ln
Expand Down Expand Up @@ -112,3 +113,8 @@ fun FontMetrics.center(text: String, width: Int) = kotlin.math.max((width - stri
* Load an image.
*/
fun loadImage(name: String) = ImageIO.read(object {}::class.java.getResourceAsStream(name))

/**
* A convenience function to load a custom font from resources.
*/
fun loadFont(name: String) = Font.createFont(Font.TRUETYPE_FONT, object {}::class.java.getResourceAsStream(name))
19 changes: 17 additions & 2 deletions src/main/kotlin/crackers/kobots/parts/movement/Rotator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,30 @@ class BasicStepperRotator(
reversed: Boolean = false
) : Rotator {

private val maxSteps: Float

private val degreesToSteps: Map<Int, Int>
init {
require(gearRatio > 0f) { "gearRatio '$gearRatio' must be greater than zero." }
maxSteps = theStepper.stepsPerRotation / gearRatio

// calculate how many steps off of "zero" each degree is
degreesToSteps = (0..359).map {
it to (maxSteps * it / 360).roundToInt()
}.toMap()
}

private val forwardDirection = if (reversed) BACKWARD else FORWARD
private val backwardDirection = if (reversed) FORWARD else BACKWARD

fun release() = theStepper.release()

private val maxSteps = theStepper.stepsPerRotation / gearRatio
private var stepsLocation: Int = 0

override fun current(): Int = (360 * stepsLocation / maxSteps).roundToInt()

override fun rotateTo(angle: Int): Boolean {
val destinationSteps = (maxSteps * angle / 360).roundToInt()
val destinationSteps = degreesToSteps[abs(angle % 360)]!! * (if (angle < 0) -1 else 1)
if (destinationSteps == stepsLocation) return true

// move towards the destination
Expand All @@ -141,6 +149,13 @@ class BasicStepperRotator(
// are we there yet?
return destinationSteps == stepsLocation
}

/**
* TODO replace this with a translation table or something to account for "drift" (probably due to all the rounding)
*/
fun reset() {
stepsLocation = 0
}
}

/**
Expand Down
Loading

0 comments on commit 95ed622

Please sign in to comment.