My Lights Run on Bash
Among the many hobbies that modern nerds pick up, one has recently become incredibly popular: making everything in your house dangerously unreliable by inserting a bunch of software where previously simple wires had sufficed. In this post, I describe how I replaced the wires between my lights and my light switches with some Bash.
Bash as critical infrastructure? Am I alright? Do I need to see a psychiatrist? These are all good questions, but let's leave them for another post.
To start with, some background. I liked the idea of smart lights, not for random colours, but for the ability to control them remotely, to dim them, and to change their colour temperature. I had the dream of lights that would change in temperature over the course of the day, so that during winter you could get more daylight-feeling light for the duration of waking hours.
The first choice on this journey was the hardware, but that's not the focus of this post. In short, I went with Zigbee. This meant I needed software that could interact with a Zigbee coordinator and expose some kind of API. For this purpose, I begrudgingly picked Zigbee2MQTT. I had considered writing my own interface, but upon investigation, it became clear that this wouldn't be a very easy task.
Zigbee2MQTT manages the Zigbee coordinator hardware and exposes your Zigbee network over the MQTT protocol.
So, now I had my Zigbee devices exposed over MQTT. This was already powerful, as I could now use mosquitto_pub
to turn things on and off in my house. But the problem was that I couldn't react to events in complicated ways.
Most people would at this point install some off-the-shelf home automation software on their personal Kubernetes cluster. But I don't have a Kubernetes cluster, and if your software's installation page starts with two options, one being an entire OS and the other a container, forgive me if I get the strong urge to try something else first.
I spent some time brainstorming ideas, including visions of a simple web application to manage Lua scripts. But I kept digging away at layers of complexity, and questioning the need for certain features, until I realised that all I really needed was an easy way to launch arbitrary programs in response to MQTT messages. This could have been done with mosquitto_sub
and Bash, but for a number of reasons that would have been somewhat tricky and potentially unreliable. Instead, I wrote a small program.
The project is called MQTTR for MQTT router (boring name, I know). It's the backbone of the operation. You can read a bit more about it on its project page.
One of the first things I did was the most simple: I wrote a script to toggle my lights with both my wall light switches and the on-off button on the dimmers1.
Here is the script2:
#!/usr/bin/env bash
topic=$1
[[ $topic =~ ^zigbee2mqtt/([^/]*)/light_([^/]*)$ ]] || exit 0
room=${BASH_REMATCH[1]}
action=$(jq -r .action)
case ${BASH_REMATCH[2]} in
switch) [[ $action == toggle ]] || exit 0;;
dimmer) [[ $action == on_press_release ]] || exit 0;;
*) exit 0;;
esac
mosquitto_pub --topic "zigbee2mqtt/$room/light/set" --message '{"state":"TOGGLE"}'
The script is configured, using MQTTR, to run in response to messages matching the filter zigbee2mqtt/+/+/
. The filter matches both zigbee2mqtt/$room/light_dimmer
and zigbee2mqtt/$room/light_switch
, which is the naming pattern for the dimmer and switch for each light in the house.
Since MQTT subscriptions are rather coarse-grained3, the first thing the script does is use Bash's regex matching capabilities to match the message topic (passed as $1
) against what is expected. This also allows the type of light controller, and the room of the light, to be pulled out of the topic string for use later.
Next, the JSON-formatted MQTT message sent by Zigbee2MQTT is parsed using jq
to pull out the action
field that indicates, in the case of the dimmer, which button was pressed, held, or released. The script then filters on the basis of this field.
Finally, mosquitto_pub
tells Zigbee2MQTT to toggle the light state in the relevant room.
Now, whenever I hit one of my light switches or the on/off button on a dimmer, the light in the respective room changes state. The lights fade on and off, so any added latency (which is already minimal) is not noticeable.
Getting the physical switches working in tandem with the dimmers was the most critical part of the project. With that sorted, it was time for the fun, non-essential stuff like controlling the lights from my phone.
Android has a built-in dashboard system for controlling external devices. It allows applications on your phone to register controls in a uniform UI. It's a cool feature, but writing a whole Android application was not on my to-do list. Fortunately, someone has already written that part.
MqttDroid allows you to connect to an MQTT server and configure Android device controls that interact with it. This sounded like the perfect solution, except that Zigbee2MQTT uses JSON messages4 and MqttDroid doesn't provide a way to interact with JSON. For example, when configuring a light switch, MqttDroid expects an MQTT endpoint which contains one of two values for the light state.
This wasn't going to work as well as I hoped. While a "trigger" style control could still send {"state": "TOGGLE"}
, it wouldn't show the current light state or allow me to easily control the brightness. Completely unplayable5.
Bash to the rescue! I wrote more Bash to mirror the Zigbee2MQTT JSON state to a series of retained topics in a different namespace. For example, house/bedroom/light/brightness
now contains the bedroom light brightness as a simple number. This was achieved with the following script (filter: zigbee2mqtt/+/light
):
#!/usr/bin/env bash
topic=$1
[[ $topic =~ ^zigbee2mqtt/([^/]*)/light$ ]] || exit 0
room=${BASH_REMATCH[1]}
mapfile -t -d '' state < <(jq --raw-output0 '.brightness, .color_temp, .state')
mosquitto_pub --retain --topic "house/$room/light/brightness" --message "${state[0]}"
mosquitto_pub --retain --topic "house/$room/light/color_temp" --message "${state[1]}"
mosquitto_pub --retain --topic "house/$room/light/state" --message "${state[2]}"
Now I could configure MqttDroid to use house/$room/light/state
to inquire about the current light state. Since the messages are published with --retain
, the state is cached by the broker so there's no need to wait for a message to arrive.
The only thing left was the other side of the equation—exposing6 a simple MQTT topic to which messages with the raw brightness, colour temperature, or state could be published, and which would result in the correct JSON message being sent to the relevant Zigbee2MQTT endpoint.
It was time for more bash (filter: house/+/+/+/set
) 7:
#!/usr/bin/env bash
topic=$1
[[ $topic =~ ^house/([^/]*/[^/]*)/([^/]*)/set$ ]] || exit 0
path=${BASH_REMATCH[1]}
key=${BASH_REMATCH[2]}
value=$(cat)
jq --null-input --compact-output \
--arg key "$key" --arg value "$value" '{$key: $value}' | \
mosquitto_pub --topic "zigbee2mqtt/$path/set" --stdin-file
At this point, I was pretty satisfied. I was able to control the brightness, colour temperature, and state of the lights (and the one smart plug in my house) all from my phone, dimmer remotes, or physical wall switches.
All that was left was the spare button on each dimmer remote.
If you pair the remote directly to each light, this button cycles through colour temperatures. But this feature is not available if the dimmer is connected to a coordinator (even via direct binding). That being said, this feature wasn't too difficult to replicate with another bash script (filter: zigbee2mqtt/+/light_dimmer
):
#!/usr/bin/env bash
topic=$1
[[ $topic =~ ^zigbee2mqtt/([^/]*)/light_dimmer$ ]] || exit 0
room=${BASH_REMATCH[1]}
action=$(jq -r .action)
[[ $action == off_press_release ]] || exit 0
temps=(153 250 370 454)
color_temp=$(mosquitto_sub -t "house/$room/light/color_temp" -C 1)
closest=0
for i in "${!temps[@]}"; do
(( (temps[closest] - color_temp)**2 > (temps[i] - color_temp)**2 )) &&
closest=$i
done
color_temp=${temps[(closest+1)%${#temps[@]}]}
mosquitto_pub -t "house/$room/light/color_temp/set" -m "$color_temp"
This one is the most advanced of them all, and a portion (the find-nearest temperature and pick-next bit) was written by my wonderful fiancée of crazy bash nonsense fame.
This is the current state of my home automation. I am very satisfied with it, even though it still doesn't handle progressive colour temperature changes over the course of a day yet (more excuses to write Bash).
Binding from the wall switch is not currently supported. Binding from the dimmer had a state synchronisation issue I wasn't expecting; the dimmer seems to keep its own copy of the light's state, which meant that it would get out of sync and try to turn the light off when it was already off or on when it was already on.↩
There are no superfluous or missing quotes in this script. Quoting the regex would break it.
[[
is special, so$topic
and$action
will not get word-split or glob-matched.↩There's an argument to be made for subdividing the namespace further or implementing additional filtering in MQTTR, but the primary goal was simplicity.↩
The style of message that Zigbee2MQTT sends looks like:
{ "brightness": 90, "color_mode": "color_temp", "color_temp": 250, "power_on_behavior": "previous", "state": "ON", ... }
Valve please fix.↩
Regardless of your opinions of "AI", one annoying thing that has come out of all of this is that now using the em dash properly is seen as a sign of using a GPT to write for you.↩
This script is actually much more versatile than the one above. It allows any
$key
on any$path
to be set to any$value
.↩