Skip to content

Commit

Permalink
Update and add more HomeAssistant integration.
Browse files Browse the repository at this point in the history
Breaking API due to package move, but better entity setups and that sort of thing. Also **much** easier to attach a lot of entities to the same device.
  • Loading branch information
EAGrahamJr committed Jan 19, 2024
1 parent 1c22921 commit 10ae1b3
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 69 deletions.
12 changes: 12 additions & 0 deletions HomeAssistant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Home Assistant Integration via MQTT

Classes in the [mqtt/homeassistant](src/main/kotlin/crackers/kobots/mqtt/homeassistant) directory. Basically
provides an abstraction for various things to integrate themselves with Home Assistant. This includes (so far):

* Simple lights
* Simple sensors

## TODO

* Expand on the lights
* Create "multi-value" sensors
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ Contains basic application construction elements that are being used in my vario
- A simplified event-bus for in-process communication
- Wrappers around MQTT for external communications

There are two main sections.
There are three main sections.

- [Actuators and Movements](Movements.md)
- [Event Bus](EventBus.md)
- [Home Assistant](HomeAssistant.md)

Javadocs are published at the [GitHub Pages](https://eagrahamjr.github.io/kobots-parts/) for this project.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package crackers.kobots.mqtt
package crackers.kobots.mqtt.homeassistant

import crackers.kobots.app.AppCommon.mqttClient
import crackers.kobots.mqtt.KobotDevice.Companion.KOBOTS_MQTT
import crackers.kobots.mqtt.KobotsMQTT
import crackers.kobots.mqtt.homeassistant.KobotDevice.Companion.KOBOTS_MQTT
import crackers.kobots.parts.app.KobotSleep
import org.json.JSONObject
import org.slf4j.LoggerFactory
Expand Down Expand Up @@ -45,6 +46,8 @@ interface KobotDevice : Comparable<KobotDevice> {

/**
* Generate the MQTT discovery message for this device.
*
* **NOTE** This should be modified by each entity for it's special cases.
*/
fun discovery(): JSONObject

Expand All @@ -53,11 +56,6 @@ interface KobotDevice : Comparable<KobotDevice> {
*/
fun currentState(): JSONObject

/**
* Handle the MQTT command message for this device.
*/
fun handleCommand(payload: JSONObject)

override fun compareTo(other: KobotDevice): Int = uniqueId.compareTo(other.uniqueId)

companion object {
Expand All @@ -69,80 +67,72 @@ interface KobotDevice : Comparable<KobotDevice> {
* Abstraction for common attributes and methods for all Kobot devices for integration with Home Assistant via MQTT.
* Because it's part of this package, it assumes usage of the [AppCommon.mqttClient] singleton.
*
* Devices may be removed from HomeAssistant at any time, so the discovery message is sent on every connection.
* Devices may be removed from HomeAssistant at any time, so the discovery message is sent on every connection. Birth
* and last-will messages are **not** used because the client can be used for other things besides HA.
*
* **Note** the [name] property is the name of the _entity_, not the device. The _entityId_ of the device in Home
* Assistant will be constructed from the category, uniqueId, and name -- e.g. `light.sparkle_night_light`.
* **Note** the [uniqueId] and [name] properties are the name of the _entity_, not the device (see
* [DeviceIdentifier] -- a device may have several entities). The _entityId_ in Home Assistant will be constructed from
* the category, uniqueId, and name -- e.g. `light.sparkle_night_light`.
*/
abstract class AbstractKobotDevice(final override val uniqueId: String, final override val name: String) : KobotDevice {
init {
require(uniqueId.isNotBlank()) { "'uniqeId' must not be blank." }
require(name.isNotBlank()) { "'name' must not be blank." }
}

private val logger = LoggerFactory.getLogger(javaClass.simpleName)
private val conntected = AtomicBoolean(false)
private val connected = AtomicBoolean(false)
protected val homeassistantAvailable: Boolean
get() = conntected.get()
get() = connected.get()

/**
* The MQTT topic for the device's state (send).
*/
val statusTopic by lazy { "$KOBOTS_MQTT/$uniqueId/state" }

/**
* The MQTT topic for the device's command (receive).
*/
val commandTopic by lazy { "$KOBOTS_MQTT/$uniqueId/set" }
val statusTopic = "$KOBOTS_MQTT/$uniqueId/state"

/**
* A generic configuration for the device, which can be used to generate the discovery message
* because there are too many "base" configuration parameters to be able to handle them cleanly,
* so just dump it on the child class to figure it out.
*/
val baseConfiguration by lazy {
val deviceId = JSONObject().apply {
put("identifiers", listOf(uniqueId, deviceIdentifier.identifer))
put("name", deviceIdentifier.identifer)
put("model", deviceIdentifier.model)
put("manufacturer", deviceIdentifier.manufacturer)
}

JSONObject().apply {
put("command_topic", commandTopic)
put("device", deviceId)
put("entity_category", "config")
put("icon", deviceIdentifier.icon)
put("name", name)
put("schema", "json")
put("state_topic", statusTopic)
put("unique_id", uniqueId)
}
override fun discovery() = JSONObject().apply {
val deviceId = JSONObject()
.put("identifiers", listOf(uniqueId, deviceIdentifier.identifer))
.put("name", deviceIdentifier.identifer)
.put("model", deviceIdentifier.model)
.put("manufacturer", deviceIdentifier.manufacturer)

put("device", deviceId)
put("entity_category", "config")
put("icon", deviceIdentifier.icon)
put("name", name)
put("schema", "json")
put("state_topic", statusTopic)
put("unique_id", uniqueId)
}

fun start() = with(mqttClient) {
open fun start() = with(mqttClient) {
// add a (re-)connect listener to the MQTT client to send state`when the connection is re-established
addConnectListener(object : KobotsMQTT.ConnectListener {
override fun onConnect(reconnect: Boolean) {
redoConnection()
}

override fun onDisconnect() {
conntected.set(false)
connected.set(false)
}
})
// subscribe to the command topic (expecting JSON payload)
subscribeJSON(commandTopic, ::handleCommand)
// subscribe to the HA LWT topic and send discovery on "online" status
subscribe("homeassistant/status") { message ->
if (message == "online") redoConnection() else conntected.set(false)
if (message == "online") redoConnection() else connected.set(false)
}
}

/**
* Re-do the connection to HomeAssistant, which means sending the discovery message and the current state.
*/
protected open fun redoConnection() {
conntected.set(true)
connected.set(true)
sendDiscovery()
logger.info("Waiting 2 seconds for discovery to be processed")
KobotSleep.seconds(2)
Expand All @@ -166,3 +156,26 @@ abstract class AbstractKobotDevice(final override val uniqueId: String, final ov
if (homeassistantAvailable) mqttClient[statusTopic] = state
}
}

abstract class CommandDevice(uniqueId: String, name: String) : AbstractKobotDevice(uniqueId, name) {
/**
* The MQTT topic for the device's command (receive).
*/
val commandTopic = "$KOBOTS_MQTT/$uniqueId/set"

/**
* Handle the MQTT command message for this device.
*/
abstract fun handleCommand(payload: JSONObject)

/**
* Over-ride the base class to add a subscription to handle commands.
*/
override fun start() {
super.start()
// subscribe to the command topic (expecting JSON payload)
mqttClient.subscribeJSON(commandTopic, ::handleCommand)
}

override fun discovery() = super.discovery().put("command_topic", commandTopic)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package crackers.kobots.mqtt
package crackers.kobots.mqtt.homeassistant

import crackers.kobots.mqtt.LightColor.Companion.toLightColor
import crackers.kobots.mqtt.LightCommand.Companion.commandFrom
import crackers.kobots.mqtt.homeassistant.LightColor.Companion.toLightColor
import crackers.kobots.mqtt.homeassistant.LightCommand.Companion.commandFrom
import crackers.kobots.parts.kelvinToRGB
import org.json.JSONObject
import java.awt.Color
Expand Down Expand Up @@ -108,6 +108,8 @@ interface LightController {
* to address each LED individually, offset by 1.
*/
fun current(): LightState

fun controllerIcon(): String = ""
}

/**
Expand Down Expand Up @@ -137,23 +139,21 @@ val NOOP_CONTROLLER = object : LightController {
open class KobotLight(
uniqueId: String,
private val controller: LightController,
name: String = "",
val lightEffects: List<String>? = null
name: String,
val lightEffects: List<String>? = null,
override val component: String = "light",
override val deviceIdentifier: DeviceIdentifier =
DeviceIdentifier("Kobots", controller.javaClass.simpleName, controller.controllerIcon())
) :
AbstractKobotDevice(uniqueId, name) {

override val component = "light"
override val deviceIdentifier = DeviceIdentifier("Kobots", "KobotLight", "mdi:lightbulb")

override fun discovery(): JSONObject {
return baseConfiguration.apply {
put("brightness", true)
put("color_mode", true)
put("supported_color_modes", LightColorMode.entries.map { it.name.lowercase() })
if (lightEffects != null) {
put("effect", true)
put("effect_list", lightEffects)
}
CommandDevice(uniqueId, name) {

override fun discovery() = super.discovery().apply {
put("brightness", true)
put("color_mode", true)
put("supported_color_modes", LightColorMode.entries.map { it.name.lowercase() })
if (lightEffects != null) {
put("effect", true)
put("effect_list", lightEffects)
}
}

Expand Down
Loading

0 comments on commit 10ae1b3

Please sign in to comment.