Polyglot Binding Announcement

I have written a new binding and would like some feedback from testers.

Polyglot

The polyglot binding manages the lifecycle of docker containers. Polyglot can start and stop containers when Openhab starts and stops or can run containers on-demand. Additionally, the binding can provide environment variables to the docker container to support the development of bindings that communicate to Openhab using communications mechanism such as the homie convention over MQTT. Utilizing the homie convention and the MQTT protocol, automation bindings can be written in any language and integrated into Openhab.

Motivation

I wrote this binding to make it quicker and easier to build sharable integrations into Openhab. Integrations can be written in any language to be shared with the openhab community.

How does it work?

The binding provides a method to integrate docker containers and supplies a set of environment variables that containers can use to make connections to MQTT. Additionally, the binding transfers STDOUT/STDERR from the container to the Openhab log, optionally transforming the log entries and mapping them to the appropriate level.

Code Example

The following code (written in Kotlin) integrates my Nuvo Whole Home Audio system with Openhab:

import org.homieiot.PropertyType
import org.homieiot.device
import org.homieiot.mqtt.HomieMqttClient
import org.nuvo.Client
import org.nuvo.event.*
import org.nuvo.event.Next
import org.nuvo.event.PlayPause
import org.nuvo.event.Prev
import org.nuvo.message.transmit.*
import kotlin.properties.Delegates
import kotlin.properties.ObservableProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

enum class Command {
    NEXT, PLAYPAUSE, PREV
}

fun nuvoHost() = requireNotNull(System.getenv("nuvo.host")) { "nuvo.host must be set as an environment variable" }

private val client = Client(nuvoHost())

fun main() {

    val device = device(id = "nuvo") {
        node(id = "source", name = "NuVo Source", type = "Audio Source", range = 1..6) { source ->
            for (lineNum in 1..4) {
                string(id = "displayline$lineNum", name = "Source $source Display Line $lineNum") {
                    eventBusUpdate<SourceDisplay> { if (it.source == source && it.lineNumber == lineNum) update(it.line) }
                    subscribe {
                        client.sendMessage(
                            SetSourceDisplayLine(
                                source = source,
                                lineNumber = lineNum,
                                line = it.update
                            )
                        )
                    }
                }
            }
            val trackStatus = TrackStatusPublisher(client, source)

            number(id = "duration", name = "Source $source Track Duration") {
                eventBusUpdate<TrackStatus> { if (it.source == source) update(it.duration.toLong()) }
                subscribe { trackStatus.duration = it.update }
            }
            number(id = "position", name = "Source $source Track Position") {
                eventBusUpdate<TrackStatus> { if (it.source == source) update(it.position.toLong()) }
                subscribe { trackStatus.position = it.update }
            }
            enum<Status>(id = "status", name = "Source $source Source Status") {
                eventBusUpdate<TrackStatus> { if (it.source == source) update(it.status) }
                subscribe { trackStatus.status = it.update }
            }
        }
        node(id = "zone", name = "NuVo Zone", type = "Audio Zone", range = 1..20) { zone ->
            number(id = "source", name = "Zone $zone Source") {
                eventBusUpdate<ZoneSource> { if (it.zone == zone) update(it.source.toLong()) }
                subscribe { client.sendMessage(Source(zone, it.update.toInt())) }
            }
            number(id = "volume", name = "Zone $zone Volume") {
                eventBusUpdate<ZoneVolume> { if (it.zone == zone) update(it.level.toLong()) }
                subscribe { client.sendMessage(Volume(zone, it.update.toInt())) }
            }
            bool(id = "power", name = "Zone $zone Power") {
                eventBusUpdate<ZonePower> { if (it.zone == zone) update(it.powerState == PowerState.ON) }
                subscribe { client.sendMessage(Power(zone, it.update)) }
            }
            bool(id = "mute", name = "Zone $zone Mute") {
                eventBusUpdate<ZoneMute> { if (it.zone == zone) update(it.muteState == MuteState.ON) }
                subscribe { client.sendMessage(Mute(zone, it.update)) }
            }

            enum<Command>(id = "command", name = "Zone $zone Transport Command", type = PropertyType.EVENT) {
                eventBusUpdate<Next> { if (it.zone == zone) update(Command.NEXT) }
                eventBusUpdate<Prev> { if (it.zone == zone) update(Command.PREV) }
                eventBusUpdate<PlayPause> { if (it.zone == zone) update(Command.PLAYPAUSE) }
                subscribe {
                    when (it.update) {
                        Command.PLAYPAUSE -> client.sendMessage(org.nuvo.message.transmit.PlayPause(zone))
                        Command.NEXT -> client.sendMessage(org.nuvo.message.transmit.Next(zone))
                        Command.PREV -> client.sendMessage(org.nuvo.message.transmit.Prev(zone))
                    }
                }
            }
        }
    }

    HomieMqttClient.fromEnv(device).connect()
    client.connect()
}

/**
 * This function creates an object that subscribes to updates from the event bus
 */
private inline fun <reified T> eventBusUpdate(noinline update: (t: T) -> Unit) {
    client.eventBus.listen(T::class.java).subscribe {
        update.invoke(it)
    }
}


class TrackStatusPublisher(private val client: Client, private val source: Int) {

    var duration: Long? by Delegates.observable<Long?>(null) { _, _, _ -> statusUpdate(duration, position, status) }
    var position: Long? by Delegates.observable<Long?>(null) { _, _, _ -> statusUpdate(duration, position, status) }
    var status: Status? by Delegates.observable<Status?>(null) { _, _, _ -> statusUpdate(duration, position, status) }

    private fun statusUpdate(duration: Long?, position: Long?, status: Status?) {
        if (duration != null && position != null && status != null) {
            client.sendMessage(
                SetSourceTrackStatus(
                    source,
                    duration = duration.toInt() * 10 ,
                    position = position.toInt() * 10 ,
                    status = status
                )
            )
        }
    }
}

Those 120 lines of code represent the entire integration code and create 142 Items/Triggers. This code depends on the homie-kotlin to provide a DSL to create homie objects and to communicate with openhab. A Nuvo library is also used to communicate with the Nuvo device.

Example Binding Config

polylgot.things

Bridge polyglot:containers:home [ mqttServer="tcp://mosquitto.aocboc:1883", mqttUsername="openhab", mqttPassword="<password>" ] {
   Thing container nuvo [ image="boctothefuture/nuvo-homie", tag="1.0.7", logRegex="^(?<strip>\\[(?<level>.*)\\]\\s).*", env="{'nuvo.host':'10.0.0.70','LOG_LEVEL'='DEBUG'}" ]
}

The above configuration will automatically download version 1.0.7 of the Nuvo homie docker container, install it into local registry and start it. The mqttServer, mqttUsername, and mqttPassword parameters will be converted into environment variables (MQTT_SERVER, etc) and automatically passed to all docker containers. Additionally, the logRegex will pull out the log-level so Openhab can log the line at the appropriate level and then strip out part of the line so that duplicate information is not included (log level, timestamp, etc). The env parameter is a JSON map that are additional arguments passed to the container, in this case the ip address of my Nuvo server and the log level I want the integration in the container to run at.

mqtt.things

You can rely on MQTT auto discovery, however that is not my style.

Bridge mqtt:broker:mosquitto [ host="mosquitto", secure=false, clientID="openhab", username="openhab", password="<password>" ]
{
        Thing homie300 nuvo [ deviceid="nuvo", basetopic="homie"]
}

nuvo.items

If you have the REST api enabled you can use docker container I have shared to create a skeleton items definition for the device you added.

Syntax to executor container is:
docker run -it boctothefuture/homie-items:0.0.1 <openhab_url> <deviceid>

Here is some sample output when run with my openhab url and deviceid of “nuvo” (has to match the deviceid in the mqtt.things file) and an optional --name-prefix of “Nuvo”.
docker run -it boctothefuture/homie-items:0.0.1 http://<openhab_ip>:8080 nuvo --name-prefix=Nuvo

String                 Nuvo_Source_1_Display_Line_1      {channel="mqtt:homie300:mosquitto:nuvo:source1#displayline1"}
String                 Nuvo_Source_1_Display_Line_2      {channel="mqtt:homie300:mosquitto:nuvo:source1#displayline2"}
String                 Nuvo_Source_1_Display_Line_3      {channel="mqtt:homie300:mosquitto:nuvo:source1#displayline3"}
String                 Nuvo_Source_1_Display_Line_4      {channel="mqtt:homie300:mosquitto:nuvo:source1#displayline4"}
Number                 Nuvo_Source_1_Track_Duration      {channel="mqtt:homie300:mosquitto:nuvo:source1#duration"}
Number                 Nuvo_Source_1_Track_Position      {channel="mqtt:homie300:mosquitto:nuvo:source1#position"}
String                 Nuvo_Source_1_Source_Status       {channel="mqtt:homie300:mosquitto:nuvo:source1#status"}
... (Similar output skipped)
//Trigger Channel                                        {channel="mqtt:homie300:mosquitto:nuvo:zone1#command"}
Switch                 Nuvo_Zone_1_Mute                  {channel="mqtt:homie300:mosquitto:nuvo:zone1#mute"}
Switch                 Nuvo_Zone_1_Power                 {channel="mqtt:homie300:mosquitto:nuvo:zone1#power"}
Number                 Nuvo_Zone_1_Source                {channel="mqtt:homie300:mosquitto:nuvo:zone1#source"}
Number                 Nuvo_Zone_1_Volume                {channel="mqtt:homie300:mosquitto:nuvo:zone1#volume"}
... (Similar output skipped)

Benefits

  • No XML for defining things and or channels (The homie protocol takes care of that)
  • The integration is isolated from Openhab (Can’t cause OoM exception on Openhab, doesn’t need to worry about Openhab threading, schedulers, or lifecycle).
  • You can write integrations for openhab in whatever language you want. Homie Implementations list is here

Drawbacks

  • Requires additional compute resources compared to a normal binding integration
  • Limited to the types supported by the overlap of Homie/Openhab
  • Updates / Commands will be delayed by the processing time between of the MQTT broker, OpenHab MQTT binding and the Homie MQTT library being used. In practice on my system this has been very fast.

Resources

  • Binding documentation is available here
  • Binding can be downloaded here

Requirements

  • Openhab 2.4
  • MQTT 2 binding

Installation

Copy the jar file into your addons directory.

8 Likes

What if I run OH itself in a Docker container?

I haven’t actually tested it, but I did think about it because I want to move my OpenHab into a container.
I am using the spotify docker library and I think one would need to either mount the /var/run/docker/docker.sock in the OH container or enable the HTTP(S) socket for docker and connect to that.

Is “binding” the correct concept here? I think “ExtensionService” is the right one.

An ExtensionService discovers / provides extensions to be installed.
The flow would be:

  1. A docker container managed / discovered by Polyglot is exposed as openHAB extension.
  2. Such an extension can be “installed” and “configured”.
  3. As soon as it is configured the started docker container exposes MQTT Homie Things.

Cheers, David

Hello,
Where do you put the example code?
Thanks
Arnaud

I would be interested in this as well. Which version of nuvo does this work for?

I have only tested it with an MPS4 connected to a Grand Concerto.