Automating the Hue Tap Dial Switch in Elixir via MQTT

I recently bought a couple of Hue Tap Dial switches for our house to enhance our smart home. We have quite a few smart lights and I figured the tap dial—with it’s multiple buttons and rotary dial—would be a good fit.
Since I’ve been moving away from standard Home Assistant automations to my own automation engine in Elixir I had to figure out how best to integrate the tap dial.
At first I tried to rely on my existing Home Assistant connection but I realized that it’s better to bypass Home Assistant and go directly via MQTT, as I already use Zigbee2MQTT as the way to get Zigbee devices into Home Assistant.
This post walks through how I set it all up and I’ll end up with an example of how I control multiple Zigbee lights from one dial via Elixir.
The direct zigbee binding is good but not great
I’m a huge fan of using direct bindings in Zigbee to directly pair switches to lights. This way the interaction is much snappier; instead of going through:
device 1 -> zigbee2mqtt -> controller -> zigbee2mqtt -> device 2
The communication can instead go:
device 1 -> device 2
It works if my server is down, which is a huge plus for important functionality such as turning on the light in the middle of the night when one of the kids have soiled the bed. That’s not the time you want to debug your homelab setup!
The Hue Tap Dial can be bound to lights with Zigbee2MQTT and dimming the lights with the dial feels very smooth and nice. You can also rotate the dial to turn on and off the light, like a normal dimmer switch.
Unfortunately, if you want to bind the dimmer it also binds the hold of all of the four buttons to turn off the light, practically blocking the hold functionality if you use direct binding. There’s also no way to directly bind a button press to turn on or toggle the light—dimming and hold to turn off is what you get.
To add more functionality you have to use something external; a Hue Bridge or Home Assistant with a Zigbee dongle works, but I wanted to use Elixir.
Communicating with MQTT in Elixir
The first thing we need to do is figure out is how to receive MQTT messages and how to send updates to Zigbee devices.
Connecting and subscribing to changes
I found the tortoise311 library that implements an MQTT client and it was quite pleasant to use.
First we’ll start a Tortoise311.Connection
in our main Supervisor tree:
[
# Remember to generate a unique id if you want to connect multiple clients
# to the same MQTT service.
client_id: :my_unique_client_id,
# They don't have to be on the same server.
server: ,
# Messages will be sent to `Haex.MqttHandler`.
handler: ,
# Subscribe to all events under `zigbee2mqtt`.
subscriptions: []
]},
I’ll also add Phoenix PubSub to the Supervisor, which we’ll use to propagate MQTT messages to our automation:
,
When starting Tortoise311.Connection
above we configured it to call the Haex.MqttHandler
whenever an MQTT message we’re subscribing to is received.
Here we’ll simply forward any message to our PubSub, making it easy for anyone to subscribe to any message, wherever they are:
use Tortoise311.Handler
alias Phoenix.PubSub
payload = Jason.decode!(payload)
PubSub.broadcast!(Haex.PubSub, Enum.join(topic, ), )
end
end
Then in our automation (which in my automation system is a regular GenServer) we can subscribe to the events the Tap Dial creates:
use GenServer
alias Phoenix.PubSub
@impl true
# `My tap dial` is the name of the tap dial in zigbee2mqtt.
PubSub.subscribe(Haex.PubSub, )
end
@impl true
dbg(payload)
end
end
If everything is setup correctly we should see messages like these when we operate the Tap Dial:
payload #=> %{
"action" => "button_1_press_release",
...
}
payload #=> %{
"action" => "dial_rotate_right_step",
"action_direction" => "right",
"action_time" => 15,
"action_type" => "step",
...
}
Controlling devices
To change the state of a device we should send a json payload to the “set” topic.
For example, to turn off a light named My hue light
we should send the payload
to
zigbee2mqtt/My hue light/set
.
Here’s a function to send payloads to our light:
Tortoise311.publish(
# Important that this id matches the `client_id`
# we gave to Tortoise311.Connection.
:my_unique_client_id,
,
Jason.encode!(payload)
)
end
Button presses
With the MQTT communication done, we can start writing some automations.
Normal press
Here’s how we can toggle the light on/off when we click the first button on the dial in our GenServer:
set(%)
end
(Remember that we subscribed to the
topic during
init
.)
Hold
You can also hold a button, which generates a hold
and a hold_release
event.
Here’s how to use them to start moving through the hues of a light when you hold down a button and stop when you release it.
set(%)
end
set(%)
end
Double clicking
How about double clicking?
You could track the timestamp of the presses in the GenServer state and check the duration between them to determine if it’s a double click or not; maybe something like this:
double_click_limit = 350
now = DateTime.utc_now()
if state[:last_press] &&
DateTime.diff(now, state[:last_press], :millisecond) < double_click_limit do
# If we double clicked.
set(%)
else
# If we single clicked.
set(%)
end
end
This however executes an action on the first and second click. To get around that we could add a timeout for the first press by sending ourselves a delayed message, with the downside of introducing a small delay for single clicks:
double_click_limit = 350
now = DateTime.utc_now()
if state[:last_press] &&
DateTime.diff(now, state[:last_press], :millisecond) < double_click_limit do
set(%)
# The double click clause is the same as before except we also remove `click_ref`
# to signify that we've handled the interaction as a double click.
state =
state
|> Map.delete(:last_press)
|> Map.delete(:click_ref)
else
# When we first press a key we shouldn't execute it directly,
# instead we send ourself a message to handle it later.
# Use `make_ref` signify which press we should handle.
ref = make_ref()
Process.send_after(self(), , double_click_limit)
state =
state
|> Map.put(:last_press, now)
|> Map.put(:click_ref, ref)
end
end
# This is the delayed handling of a single button press.
# If the stored reference doesn't exist we've handled it as a double click.
# If we press the button many times (completely mash the button) then
# we might enter a new interaction and `click_ref` has been replaced by a new one.
# This equality check prevents such a case, allowing us to only act on the very
# last press.
# This is also useful if we in the future want to add double clicks to other buttons.
if state[:click_ref] == ref do
set(%)
else
end
end
You can generalize this concept to triple presses and beyond by keeping a list of timestamps instead of the singular one we use in
:last_press
,
but I personally haven’t found a good use-case for them.
Dimming
Now, let’s see if we can create a smooth dimming functionality. This is surprisingly problematic but let’s see what we can come up with.
Rotating the dial produces a few different actions:
dial_rotate_left_step
dial_rotate_left_slow
dial_rotate_left_fast
dial_rotate_right_step
dial_rotate_right_slow
dial_rotate_right_fast
brightness_step_up
brightness_step_down
Let’s start with dial_rotate_*
to set the brightness_step
attribute of the light:
speed = rotate_speed(type)
set(%)
end
-rotate_speed(speed)
(speed)
rotate_speed10
20
45
This works, but the transitions between the steps aren’t smooth as the light immediately jumps to a new brightness value.
With a transition we can smooth it out:
# I read somewhere that 0.4 is standard for Philips Hue.
set(%)
It’s actually fairly decent (when the stars align).
As an alternative implementation we can try to use the brightness_step_*
actions:
% => <> dir,
=> step
}},
state
) do
step =
case dir do
-> step
-> -step
end
# Dimming was a little slow, adding a factor speeds things up.
set(%)
end
This implementation lets the tap dial itself provide the amount of steps and I do think it feels better than the dial_rotate_*
implementation.
Note that this won’t completely turn off the light and it’ll stop at brightness 1
.
We can instead provide
brightness_step_onoff: step
to allow the dimmer to turn on and off the light too.
Other types of transitions
One of the reasons I wanted a custom implementation was to be able to do other things with the rotary dial.
For example, maybe I’d like to alter the hue of light by rotating? All we have to do is set the hue instead of the brightness:
set(%)
(This produces a very cool effect!)
Another idea is to change the volume by rotating. Here’s the code that I use to control the volume of our kitchen speakers (via Home Assistant, not MQTT):
rotate: fn step ->
# A step of 8 translates to a volume increase of 3%
volume_step = step / 8 * 3 / 100
# Clamp volume to not accidentally set a very loud or silent volume.
volume =
# HAStates stores the current states in memory whenever a state is changed.
(HAStates.get_attribute(kitchen_player_ha(), :volume_level, 0.2) + volume_step)
|> Math.clamp(0.05, 0.6)
# Calls the `media_player.volume_set` action.
MediaPlayer.set_volume(kitchen_player_ha(), volume)
# Prevents a possible race condition where we use the old volume level
# stored in memory for the next rotation.
HAStates.override_attribute(kitchen_player_ha(), :volume_level, volume)
end
A use-case: the boys bedroom
We’ve got a bunch of lights in the boys bedroom that we can control and it’s a good use-case for a device such as the Tap Dial.
Lights to control
These are the lights we can control in the room:
- A ceiling light with color ambiance
- A window light with white ambiance
- A floor lamp with white ambiance
- Night lights for both Loke and Isidor, with color ambiance
- A lava lamp for Loke, connected to a smart plug
(Yes, I need to get a lava light for Isidor too. They’re awesome!)
The window light isn’t controlled by the tap dial and there are other automations that controls circadian lighting for most of the lights.
Use Zigbee direct binding
I’m opting to use direct binding because of two reasons:
- Direct binding allows us to dim the light even if the smart home server is down.
- Despite my efforts, the dimming automation has some latency issues.
Even though it overrides the hold functionality I think direct binding for lights is the way to go.
The functionality
These are the functions for the tap dial in the boys room:
- Rotate: Dim brightness of ceiling light (direct binding)
- Hold any: Turns off the ceiling light (direct binding)
- Click 1: Toggle ceiling light on/off
- Double click 1: Toggle max brightness for ceiling light on/off
- Click 2: Each click goes through different colors for the ceiling light
- Click 3: Toggle floor lamp on/off
- Double click 3: Toggle Isidor’s night light on/off
- Hold 3: Loop through the hue of Isidor’s night light
- Click 4: Toggle Loke’s lava lamp on/off
- Double click 4: Toggle Loke’s night light on/off
- Hold 4: Loop through the hue of Loke’s night light
There’s many different ways you can design the interactions and I may switch it up in the future, but for now this works well.
A generalized tap dial controller
The code I’ve shown you so far has been a little simplified to explain the general approach. As I have several tap dials around the house I’ve made a general tap dial controller with a more declarative approach.
For example, here’s how the tap dial in the boys room is defined:
TapDialController.start_link(
device: boys_room_tap_dial(),
scene: 0,
rotate: fn _step ->
# This disables the existing circadian automation.
# I found that manually disabling it is more reliable than trying to
# detect external changes over MQTT as messages may be delayed and arrive out of order.
LightController.set_manual_override(boys_room_ceiling_light(), true)
end,
button_1: % click: fn ->
Mqtt.set(boys_room_ceiling_light(), %)
end,
double_click: fn ->
# This function compares the current light status and sets it to 100%
# or reverts back to circadian lighting (if setup for the light).
HueLights.toggle_max_brightness(boys_room_ceiling_light())
end
},
button_2: % click: fn state ->
# The light controller normally uses circadian lighting to update
# the light. Setting manual override pauses circadian lighting,
# allowing us to manually control the light.
LightController.set_manual_override(boys_room_ceiling_light(), true)
# This function steps through different light states for the ceiling light
# (hue 0..300 with 60 intervals) and stores it in `state`.
next_scene(state)
end
},
button_3: % click: fn ->
Mqtt.set(boys_room_floor_light(), %)
end,
double_click: fn ->
Mqtt.set(isidor_sleep_light(), %)
end,
hold: fn ->
Mqtt.set(isidor_sleep_light(), %)
end,
hold_release: fn ->
Mqtt.set(isidor_sleep_light(), %)
end
},
button_4: % click: fn ->
Mqtt.set(loke_lava_lamp(), %)
end,
double_click: fn ->
Mqtt.set(loke_sleep_light(), %)
end,
hold: fn ->
Mqtt.set(loke_sleep_light(), %)
end,
hold_release: fn ->
Mqtt.set(loke_sleep_light(), %)
end
}
)
I’m not going to go through the implementation of the controller in detail. Here’s the code you can read through if you want:
use GenServer
alias Haex.Mqtt
alias Haex.Mock
require Logger
@impl true
# This allows us to setup expectations and to collect what messages
# the controller sends during unit testing.
if parent = opts[:parent] do
Mock.allow(parent, self())
end
device = opts[:device] || raise
# Just subscribes to pubsub under the hood.
Mqtt.subscribe_events(device)
state =
Map.new(opts)
|> Map.put_new(:double_click_timeout, 350)
end
GenServer.start_link(__MODULE__, opts)
end
@impl true
case parse_action(payload) do
->
# We specify handlers with `button_3: %{}` specs.
case fetch_button_handler(button, state) do
->
# Dispatch to action handlers, such as `handle_hold` and `handle_press_release`.
fun.(spec, state)
:not_found ->
end
->
case fetch_rotate_handler(state) do
->
call_handler(cb, step, state)
:not_found ->
end
:skip ->
end
end
# Only execute the callback for the last action.
if state[:click_ref] == ref do
call_handler(cb, Map.delete(state, :click_ref))
else
end
end
single_click_handler = spec[:click]
double_click_handler = spec[:double_click]
cond do
double_click_handler ->
now = DateTime.utc_now()
valid_double_click? =
state[:last_press] &&
DateTime.diff(now, state[:last_press], :millisecond) < state.double_click_timeout
if valid_double_click? do
# Execute a double click immediately.
state =
state
|> Map.delete(:last_press)
|> Map.delete(:click_ref)
call_handler(double_click_handler, state)
else
# Delay single click to see if we get a double click later.
ref = make_ref()
Process.send_after(
self(),
,
state.double_click_timeout
)
state =
state
|> Map.put(:last_press, now)
|> Map.put(:click_ref, ref)
end
single_click_handler ->
# No double click handler, so we can directly execute the single click.
call_handler(single_click_handler, state)
true ->
end
end
call_handler(spec[:hold], state)
end
call_handler(spec[:hold_release], state)
end
end
# If a callback expects one argument we'll also send the state,
# otherwise we simply call it.
case Function.info(handler)[:arity] do
0 ->
handler.()
1 ->
x ->
Logger.error(
)
end
end
end
case Function.info(handler)[:arity] do
1 ->
handler.(arg1)
2 ->
x ->
Logger.error(
)
end
end
step =
case dir do
-> step
-> -step
end
end
case Regex.run(, action, capture: :all_but_first) do
[button, ] ->
[button, ] ->
[button, ] ->
_ ->
:skip
end
end
Logger.debug()
:skip
end
spec = state[|> String.to_atom()]
if spec do
else
:not_found
end
end
if spec = state[:rotate] do
else
:not_found
end
end
end