MoreRSS

site iconJonas HietalaModify

A writer, developer and wannabe code monkey.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Jonas Hietala

Automating the Hue Tap Dial Switch in Elixir via MQTT

2025-09-01 08:00:00

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:

{Tortoise311.Connection,
[
# 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: {Tortoise311.Transport.Tcp, host: "localhost", port: 1883},
# Messages will be sent to `Haex.MqttHandler`.
handler: {Haex.MqttHandler, []},
# Subscribe to all events under `zigbee2mqtt`.
subscriptions: [{"zigbee2mqtt/+", 0}]
]},

I’ll also add Phoenix PubSub to the Supervisor, which we’ll use to propagate MQTT messages to our automation:

{Phoenix.PubSub, name: Haex.PubSub},

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:

defmodule Haex.MqttHandler do
use Tortoise311.Handler
alias Phoenix.PubSub
def handle_message(topic, payload, state) do
payload = Jason.decode!(payload)
PubSub.broadcast!(Haex.PubSub, Enum.join(topic, "/"), {:mqtt, topic, payload})
{:ok, state}
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:

defmodule Automation.TapDialExample do
use GenServer
alias Phoenix.PubSub
@impl true
def init(_opts) do
# `My tap dial` is the name of the tap dial in zigbee2mqtt.
PubSub.subscribe(Haex.PubSub, "zigbee2mqtt/My tap dial")
{:ok, %{}}
end
@impl true
def handle_info({:mqtt, _topic, payload}, state) do
dbg(payload)
{:noreply, state}
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 {"state": "OFF"} to zigbee2mqtt/My hue light/set.

Here’s a function to send payloads to our light:

def set(payload) do
Tortoise311.publish(
# Important that this id matches the `client_id`
# we gave to Tortoise311.Connection.
:my_unique_client_id,
"zigbee2mqtt/My hue light/set",
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:

def handle_info({:mqtt, _topic, %{"action" => "button_1_press_release"}}, state) do
set(%{state: "TOGGLE"})
{:noreply, state}
end

(Remember that we subscribed to the "zigbee2mqtt/My tap dial" 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.

def handle_info({:mqtt, _topic, %{"action" => "button_3_hold"}}, state) do
set(%{hue_move: 40, color: %{saturation: 100}})
{:noreply, state}
end
def handle_info({:mqtt, _topic, %{"action" => "button_3_hold_release"}}, state) do
set(%{hue_move: 0})
{:noreply, state}
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:

def handle_info({:mqtt, _topic, %{"action" => "button_2_press_release"}}, state) do
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(%{color: %{hue: 60}})
{:noreply, Map.delete(state, :last_press)}
else
# If we single clicked.
set(%{color: %{hue: 180}})
{:noreply, Map.put(state, :last_press, now)}
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:

def handle_info({:mqtt, _topic, %{"action" => "button_2_press_release"}}, state) do
double_click_limit = 350
now = DateTime.utc_now()
if state[:last_press] &&
DateTime.diff(now, state[:last_press], :millisecond) < double_click_limit do
set(%{color: %{hue: 180}})
# 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)
{:noreply, state}
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(), {:execute_single_press, ref}, double_click_limit)
state =
state
|> Map.put(:last_press, now)
|> Map.put(:click_ref, ref)
{:noreply, state}
end
end
# This is the delayed handling of a single button press.
def handle_info({:execute_single_press, ref}, state) do
# 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(%{color: %{hue: 60}})
{:noreply, Map.delete(state, :click_ref)}
else
{:noreply, state}
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:

def handle_info({:mqtt, _topic, %{"action" => "dial_rotate_" <> type}}, state) do
speed = rotate_speed(type)
set(%{brightness_step: speed})
{:noreply, state}
end
defp rotate_speed("left_" <> speed), do: -rotate_speed(speed)
defp rotate_speed("right_" <> speed), do: rotate_speed(speed)
defp rotate_speed("step"), do: 10
defp rotate_speed("slow"), do: 20
defp rotate_speed("fast"), do: 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(%{brightness_step: speed, transition: 0.4})

It’s actually fairly decent (when the stars align).

As an alternative implementation we can try to use the brightness_step_* actions:

def handle_info(
{:mqtt, _topic,
%{
"action" => "brightness_step_" <> dir,
"action_step_size" => step
}},
state
) do
step =
case dir do
"up" -> step
"down" -> -step
end
# Dimming was a little slow, adding a factor speeds things up.
set(%{brightness_step: step * 1.5, transition: 0.4})
{:noreply, state}
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(%{hue_step: step * 0.75, transition: 0.4})

(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:

  1. Direct binding allows us to dim the light even if the smart home server is down.
  2. 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(), %{state: "TOGGLE"})
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(), %{state: "TOGGLE"})
end,
double_click: fn ->
Mqtt.set(isidor_sleep_light(), %{state: "TOGGLE"})
end,
hold: fn ->
Mqtt.set(isidor_sleep_light(), %{hue_move: 40, color: %{saturation: 100}})
end,
hold_release: fn ->
Mqtt.set(isidor_sleep_light(), %{hue_move: 0})
end
},
button_4: %{
click: fn ->
Mqtt.set(loke_lava_lamp(), %{state: "TOGGLE", brightness: 254})
end,
double_click: fn ->
Mqtt.set(loke_sleep_light(), %{state: "TOGGLE"})
end,
hold: fn ->
Mqtt.set(loke_sleep_light(), %{hue_move: 40, color: %{saturation: 100}})
end,
hold_release: fn ->
Mqtt.set(loke_sleep_light(), %{hue_move: 0})
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:

defmodule Haex.Mqtt.TapDialController do
use GenServer
alias Haex.Mqtt
alias Haex.Mock
require Logger
@impl true
def init(opts) do
# 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 "Must specify `device`, got: #{inspect(opts)}"
# Just subscribes to pubsub under the hood.
Mqtt.subscribe_events(device)
state =
Map.new(opts)
|> Map.put_new(:double_click_timeout, 350)
{:ok, state}
end
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def handle_info({:mqtt, _topic, payload}, state) do
case parse_action(payload) do
{:button, button, fun} ->
# We specify handlers with `button_3: %{}` specs.
case fetch_button_handler(button, state) do
{:ok, spec} ->
# Dispatch to action handlers, such as `handle_hold` and `handle_press_release`.
fun.(spec, state)
:not_found ->
{:noreply, state}
end
{:rotate, step} ->
case fetch_rotate_handler(state) do
{:ok, cb} ->
call_handler(cb, step, state)
:not_found ->
{:noreply, state}
end
:skip ->
{:noreply, state}
end
end
def handle_info({:execute_single_press, ref, cb}, state) do
# Only execute the callback for the last action.
if state[:click_ref] == ref do
call_handler(cb, Map.delete(state, :click_ref))
else
{:noreply, state}
end
end
defp handle_press_release(spec, state) do
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(),
{:execute_single_press, ref, single_click_handler},
state.double_click_timeout
)
state =
state
|> Map.put(:last_press, now)
|> Map.put(:click_ref, ref)
{:noreply, state}
end
single_click_handler ->
# No double click handler, so we can directly execute the single click.
call_handler(single_click_handler, state)
true ->
{:noreply, state}
end
end
defp handle_hold(spec, state) do
call_handler(spec[:hold], state)
end
defp handle_hold_release(spec, state) do
call_handler(spec[:hold_release], state)
end
defp call_handler(nil, state) do
{:noreply, state}
end
defp call_handler(handler, state) do
# 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.()
{:noreply, state}
1 ->
{:noreply, handler.(state)}
x ->
Logger.error(
"Unsupported cb arity `#{x}` for #{state.device} tap dial expecting 0 or 1 args"
)
{:noreply, state}
end
end
defp call_handler(nil, _arg1, state) do
{:noreply, state}
end
defp call_handler(handler, arg1, state) do
case Function.info(handler)[:arity] do
1 ->
handler.(arg1)
{:noreply, state}
2 ->
{:noreply, handler.(arg1, state)}
x ->
Logger.error(
"Unsupported cb arity `#{x}` for #{state.device} tap dial expecting 1 or 2 args"
)
{:noreply, state}
end
end
defp parse_action(%{"action" => "brightness_step_" <> dir, "action_step_size" => step}) do
step =
case dir do
"up" -> step
"down" -> -step
end
{:rotate, step}
end
defp parse_action(%{"action" => action}) do
case Regex.run(~r/button_(\d)_(\w+)/, action, capture: :all_but_first) do
[button, "press_release"] ->
{:button, String.to_integer(button), &handle_press_release/2}
[button, "hold"] ->
{:button, String.to_integer(button), &handle_hold/2}
[button, "hold_release"] ->
{:button, String.to_integer(button), &handle_hold_release/2}
_ ->
:skip
end
end
defp parse_action(_action) do
Logger.debug("Skip fallback tap dial action: #{inspect(action)}")
:skip
end
defp fetch_button_handler(button, state) do
spec = state["button_#{button}" |> String.to_atom()]
if spec do
{:ok, Map.put(spec, :button, button)}
else
:not_found
end
end
defp fetch_rotate_handler(state) do
if spec = state[:rotate] do
{:ok, spec}
else
:not_found
end
end
end

I'll only buy devices with GrapheneOS

2025-08-28 08:00:00

Cops say criminals use a Google Pixel with GrapheneOS — I say that’s freedom

We’re in a dark time period right now.

Authoritarianism is on the rise throughout the globe. Governments wants to monitor your social media accounts so they can make you disappear if you engage in wrongthink such as opposing wars or genocide. This is worsened by misguided laws like Chat control that would mandate scanning of all digital communication, exposing any wrongthink in your “private messages”.

A rational reaction to threats is to “shell up” and try to make your personal space safe. This is increasingly difficult as the devices you buy often doesn’t feel like yours anymore. Files are moved to the cloud without your knowledge; companies are doing everything they can to prevent you from blocking the ads they’re shoving in everywhere; and everything you do will soon be ingested by an LLM in order to present personalized slop to you (even your passwords and screenshots of any nasty porn habits you may have).

While you can avoid most of this crap on computers (try Linux if you haven’t) the situation on smartphones is much bleaker. Apple has been blocking sideloading apps for years and Google will soon follow by only allowing apps from verified developers to be installed on Android, preventing you from installing what you want.

(They claim it’s “for security” but it’s obvious they’re doing this to protect their income stream. Apple takes a ridiculous 30% cut from all sales in their walled garden and Google hates the ability to strip out their ads.)

I like the idea of a “dumb phone” but I unfortunately need and want apps on my phone (I consider banking and authentication apps essential to surviving in the modern world, and sometimes you must run an Android or iOS app). A “degoogled” Android-compatible operating system is the answer I see, with GrapheneOS as the exceptional standout.

The big dragons recognize this as for example Samsung removes bootloader unlocking and the EU age verification app may ban Android systems not licensed by Google. In true Streisand fashion this only makes me more motivated to fight back.

GrapheneOS works and it’s convenient

Installing GrapheneOS on the Pixel Tablet.

When people talk about GrapheneOS they will understandably focus on the privacy and security aspect. I’ll go into it a later but I think it’s important to first dispel the idea that GrapheneOS is only for the hardcore tech savvy user or that you’ll have to sacrifice a lot of functionality.

While GrapheneOS isn’t quite as simple as stock Android (I had to tweak a few settings to get some apps working) the experience has been very smooth.

Installation

The installation was straightforward and worked without a hitch:

  1. Plug in the phone or tablet via an USB
  2. Launch Chrome (or chromium)
  3. Follow the instructions and click some buttons

App compatibility

Some of the apps I’ve got installed. I use the Kvaesitso launcher.

Apps are also just as easy to install as on stock Android. I’ve installed most of the apps from the Play Store just as I would’ve on stock and they work just fine.

So far I’ve had a grand total of two issues with any apps I’ve tried:

  1. At first I couldn’t copy BankID over from my old phone.

    I had to tweak some permissions and disable some of the location privacy features of GrapheneOS before the phones recognized each other. Presumably there’s some security measure there so that you can only copy it if the phones are nearby.

    I had to play with some of these location settings to be able to copy BankID from my old phone to the new one.

  2. The AI detection feature of MacroFactor refused to work.

    MacroFactor is a food tracking app where you can take a photo and it’ll try to infer the food from the picture but uploading the photo simply failed. It’s a pretty cool feature and I instead use the app Cronometer that has the same functionality.

    MacroFactor uses Play Integrity which may in some cases break certain apps. I’ve got a few other apps that also uses Play Integrity but they don’t have any issues.

I was worried that I’d run into issues with the banking apps as I know there are some banking apps that have issues, but all the Swedish banking apps I’ve tried work well.

You can of course install apps from other sources such as F-Droid, Accrescent, Obtanium, or manually as well.

No bloatware

Finally, I love that there’s no bloatware bullshit on GrapheneOS. There are no shitty vendor specific apps that you cannot uninstall and it isn’t trying to trick you into installing stupid games via dark patterns. The downside is that GrapheneOS doesn’t come with a lot of customization and lets you install apps for that yourself.

In short, it feels like with GrapheneOS you’re in control, not some mega corporation.

The best privacy & security in a modern phone

While other Android compatible distributions such as CalyxOS and LineageOS all mention privacy and security as benefits they’re nothing like GrapheneOS. In some cases—as with CalyxOS where security updates have seen significant delays—they may provide even less security compared to stock Android.

Everything I’ve read suggests that GrapheneOS takes security and privacy very seriously. I feel that sometimes they may take a too extreme stance but I can respect that, despite being overkill for the threat levels I care about.

See for example this comparison of Android-based Operating Systems.

What about Apple then? Aren’t they great at privacy and security? While I’m sure they’ll respect your privacy more than Google, I just can’t trust a company that shoves ads into their wallet app.

Pixel devices, the big drawback with GrapheneOS

It would’ve been great to have more choices. If I only looked at hardware I might have gotten the Fairphone 5 as I like the idea of repairability, ethically sourced components, and a phone made in the EU. I also like the idea of a smaller phone and a Flip phone would’ve been great. Or maybe a really cheap phone (or tablet) as I don’t care that much about performance and could save some money.

But alas, GrapheneOS only support Pixel devices (for now). There’s a handful of phones to choose from but only a single tablet.

I guess the best way to degoogle right now is to buy from Google… So I got the Pixel 9a for myself and the Pixel Tablet for our family. (Admittedly, they’ve been pretty great.)

Software is more important than hardware

I’ve had an interesting shift when I evaluate mobile devices; instead of comparing phones primarily by hardware I prioritize the software on the phone.

Today, more than ever, hardware upgrades in new phones provide diminishing returns for ever increasing prices. There’s little practical difference between a new phone and a phone from a few years ago and savvy people can save a lot of money by simply avoiding the constant stream of new releases.

Before I bought the Pixel 9a I used the Fairphone 4 for almost 4 years, and it was performing just fine! If I hadn’t gotten the urge of trying out GrapheneOS I would’ve still be happy with the Fairphone hardware (which was a bit underpowered already at release).

Software on the other hand is more important than ever and for me it’s what makes or breaks a phone today.

The right software will protect your privacy and help keep your device secure, while the wrong software will fill your phone with uninstallable bloatware and cripple performance after system updates (if they deign to provide them).

For example, I’ve used a Galaxy Tab A7 Lite as a dashboard for my smart home for a while and it worked great. Then I installed an update and it suddenly became extremely slow, so slow that you barely could interact with the UI without punching the damn device. Even though the hardware is great, Samsung crippled the device for no reason.

With the right software your device works for you, not against you. It’s not a lot to ask for, yet in the modern day that’s very rare indeed, and it’s why I’ll only be buying mobile devices supported by GrapheneOS for the foreseeable future.

Ditching Sonos for Music Assistant

2025-06-04 08:00:00

My general advice for life is: be a good person, and care for the people around you. And follow this one very specific rule: avoid vendor lock-in.

I’ve always wondered how to setup a sound systems around the house that you can control from your devices, such as your phone. To get a working setup it seemed you had to embrace vendor lock-in; either by committing to an entire ecosystem such as Sonos, or by relying on a service like Spotify and buying amplifiers that supports their particular integration (such as Spotify Connect).

When I wanted to replace and upgrade our old Sonos speaker I did som research and I found a promising alternative: it’s called Music Assistant and it’s great.

The Music Assistant dashboard.

The woes of Sonos

We’ve had a Sonos speaker in our kitchen for more than a decade. At first I was very happy with it; the speaker was easy to use, it integrated well with Spotify, and despite being a single fairly cheap speaker the sound was pretty good.

But gradually the experience got worse:

  1. The speaker sometimes refused to play songs via Spotify.
  2. We couldn’t connect to the speaker via the Sonos app (having to unplug the speaker and pray to the Sonos god that it would be enough to fix it).
  3. Playback randomly paused.
  4. The speaker lost WIFI connection, reconnecting after a while.
  5. Finally, the radio integration stopped working altogether (this is critical for my girlfriend who always listens to the radio).

It could be worse—at least our speaker wasn’t bricked and we (supposedly) dodged a bunch of other issues by never upgrading to the new app.

The new setup

  1. Use Music Assistant as the central controller for streaming music and radio to different players and speakers.

  2. Setup an Arylic A50+ amplifier that Music Assistant can control.

    Together with a pair of speakers it replaces the Sonos in the kitchen.

  3. Connect Music Assistant with Home Assistant to control playback via our smart-home dashboard and automations.

  4. Setup more players for Music Assistant to control.

What is Music Assistant and why is it awesome?

Music Assistant is a service that acts as a hub that connects different providers and players, letting you control the sound in your home from one central location.

Image showing how Music Assistant bridges different sources with different players. Taken from this Home Assistant blog post.

So you could have this kind of setup and let Music Assistant connect them together in whatever way you wish, including multi-room setups (if the players are from the same ecosystem, such as Airplay or Squeezebox):

Provider Player
Spotify Sonos
Audible Chromecast
Radio Media player in Home Assistant
Plex Streaming amplifier (Squeezebox)
Local storage Computer with Linux (Squeezebox)

Here’s a few reasons why I think Music Assistant is awesome:

  • Freedom from the limitations of one ecosystem.
  • Can mix and match music from local storage and Spotify in the same playlist.
  • One place to control all your speakers of varying types and connections.
  • Integrates well with Home Assistant.

How to set it up

  1. Install Music Assistant

    I host my homelab things using docker compose and it was as simple as:

    services:
    music-assistant-server:
    image: ghcr.io/music-assistant/server:latest
    container_name: music-assistant-server
    restart: unless-stopped
    # Network mode must be set to host for MA to work correctly
    network_mode: host
    volumes:
    - ./music-assistant-server/data:/data/
    # privileged caps (and security-opt) needed to mount
    # smb folders within the container
    cap_add:
    - SYS_ADMIN
    - DAC_READ_SEARCH
    security_opt:
    - apparmor:unconfined
    environment:
    - LOG_LEVEL=info
    # And home assistant and other things.
  2. Add providers

    A provider is a source of music. There’s a bunch of them but at the moment I only use a few:

    The Spotify provider for example should automatically sync all Spotify playlists into Music Assistant and allows you to search and play any song on Spotify.

  3. Add players

    We need players to play our music, here’s what I currently use:

    The players should be automatically added as long as they have a matching provider enabled.

Open-source music management—particularly on Linux—has a reputation of being notoriously troublesome. But I’ve gotta say, Music Assistant was simple to setup and it works well (except issues with some players that I’ll get to shortly).

Trying out different players

I was fairly lost in what kind of amplifier and music player I should get.

There’s a lot of options out there but I was worried about paying a lot of money for something I wasn’t sure it would integrate well into my smart home setup. Here’s a few options I’ve tried:

Sonos

The Sonos speaker has found new life in our washing room.

It’s pretty funny, the Sonos speaker works better with Music Assistant than with the Sonos app. The radio completely stopped working via the Sonos app, while I can use Music Assistant to play the radio on the Sonos speaker.

The speaker might still disconnect or stop playback at odd times but it’s good enough to raise the mood in the washing room.

Squeezelite on Linux

Our living room setup with a TV, game consoles, speakers, a dumb amplifier, and a computer running Void Linux.

I hate modern smart TVs with a passion so to stream we use a computer running Linux, connected to a dumb amplifier with some speakers. It works well but it makes it a bit more cumbersome to play music.

By installing Squeezelite the computer acts as a squeezebox client, effectively transforming it into a smart player for Music Assistant and Home Assistant to stream music to.

A HifiBerry player

The HifiBerry AMP2 on top of a Raspberry Pi 3, acting as the amplifier and smart music controller. All you need is to connect the passive speakers.

As I had a Raspberry Pi lying around it made sense to try the HifiBerry AMP2 that transforms the Pi into a surprisingly capable amplifier and smart music controller. For simplicity I decided to start with their operating system HifiBerryOS that hopefully should “just work” and be open enough for me to manually fix things if needed.

While it works very well as a Spotify Connect device or to play over Bluetooth I had issues with it acting as a Squeezelite or Airplay client as the volume was super low when I tried to stream to it from Music Assistant.

It might be a software issue with HifiBerryOS but I haven’t had the energy to debug it or try other OS versions. Maybe I’ll revisit it when I want to outfit another room with speakers.

Arylic A50+

I was planning to use the HifiBerry to power two new speakers in the ceiling in the kitchen to replace the Sonos. But I got stressed out by the HifiBerry not working properly and the kitchen renovation was looming ever closer so I bought the Arylic A50+, hoping that it would work better with Music Assistant.

The device supports both Airplay and Squeezebox like the HifiBerry but again there were some issue with the volume being very low. I don’t know if both devices just happen to have similar bugs, if there’s a bug with Music Assistant, or if it’s some weird compatibility issue.

Sigh.

But there’s another way to make it work; the Arylic has an excellent Home Assistant integration and if you go that way then Music Assistant can use the Arylic as a player properly. You need the LinkPlay integration in Home Assistant and then enable the Home Assistant integration in Music Assistant.

(I tried the same with the HifiBerry integration but for some reason the HifiBerry media player exposed from Home Assistant didn’t show up in Music Assistant.)

Home Assistant integration

Music Assistant integrates well with Home Assistant. Setup is straightforward:

  1. Add the Music Assistant integration in Home Assistant:

    This will expose all players in Music Assistant to Home Assistant (such as the Squeezebox players running on Linux).

  2. Enable the Home Assistant plugin in Music Assistant:

    This will expose all media players in Home Assistant to Music Assistant (such as the Arylic or the Home Assistant Voice Preview Edition).

With this setup you can setup Home Assistant actions to start a particular radio station, add a media player card to start/stop playback, or simply give Music Assistant access to more players.

In Home Assistant how do you…

  • Play the P4 Norrbotten radio station?

    action: music_assistant.play_media
    target:
    entity_id: media_player.kitchen
    data:
    media_id: P4 Norrbotten
  • Play the To Hell and Back track?

    action: music_assistant.play_media
    target:
    entity_id: media_player.kitchen
    data:
    media_id: To Hell and Back
    media_type: track
  • Play the kpop playlist?

    action: music_assistant.play_media
    target:
    entity_id: media_player.kitchen
    data:
    media_id: kpop
    media_type: playlist
  • Play the kpop playlist randomized?

    action: media_player.shuffle_set
    target:
    entity_id:
    - media_player.kitchen
    data:
    shuffle: true
    action: music_assistant.play_media
    target:
    entity_id: media_player.kitchen
    data:
    media_id: kpop
    media_type: playlist
    enqueue: replace

You get the new music_assistant.play_media action but otherwise you control the media players just as the other media player entities in Home Assistant.

It’s not perfect

Music Assistant is a fairly young open source project so minor bugs are to be expected. There are also two larger feature that I miss:

  • I’d like to have access to my library even when I’m away from home. Maybe an Android app with native volume controls, notifications, and support for offline listening?

  • A better widget for Home Assistant.

    The mini media player card.

    For basic sound controls in the kitchen I use the Mini Media Player card but I’d like a better way to filter through playlists and music via the Home Assistant Lovelace UI. I currently embed the Music Assistant dashboard itself via an iframe but the UI is a bit too clunky for the size of my tablet.

What’s next?

Still, I must admit, I’m stoked about finding a smarter way to control music throughout our house and I’m searching for an excuse to expand the setup in the future. Here are some ideas I have:

  • Speakers in the kid’s room to play music or audio books before they go to sleep.
  • More music in my exercise space (it kind of works if I increase the volume of my office speakers, but it’s not ideal).
  • A proper Hi Fi setup for true music enjoyment somewhere in the house.

Despite some integration issues and us not using Music Assistant to it’s fullest potential it has still improved our setup in a major way.

Why I'm back to Whoop (for now)

2025-06-02 08:00:00

I recently started to subscribe to Whoop again after I canceled it around a year ago. I was partly dissatisfied with my Garmin watch as a fitness and health tracker and I also wanted a new tech device to tinker with.

The plan was to write a short post about what I like and don’t like about Whoop but as usual I’m struggling with writing shorter posts.

If I had more time I would write a shorter letter.

Attributed to Blaise Pascal

The TLDR is that Whoop is still expensive and imperfect; yet it’s the most helpful smart device I’ve used and a Whoop in your underwear is the best way to track your heart rate while hugging spandex-wearing adults.

A brief history of my experiences with smart devices

I’m not an elite athlete, not even close; I’m just a programmer who struggles to stay active. As I also like to play with tech I’ve tried out various smart devices in the hopes that they can help me to live a healthier life (with various levels of success).

Here’s the devices I’ve used, in purchase order:

I had some smartband from Fitbit that I liked. When that bugged out I bought the Fitbit Versa, with a square display.

I wanted to track my sleep better, so I bought a ring. It worked well.

The Fitbit kept disconnecting, so I abandoned it for a Garmin.

I had to recharge the ring all the time so I upgraded to the newer version. (The battery of the new ring also deteriorated and I stopped using it when I had to charge it every 1–3 days.)

I wanted to track my weight so I bought a smart scale.

I can’t track my Submission Grappling training with a watch or a ring, so I bought a heart rate monitor you put on the bicep. It was okay.

Maybe I should try a real smartwatch with lots of smart features…? I use Android so maybe a watch with WearOS is good?

Jokes on me, WearOS was garbage and having to charge the watch every day is a nightmare. I went back to the Garmin.

I wasn’t happy with the Garmin Venu Sq (can’t remember why) and disappointed with smart watches I figured a simple smart band would suffice.

The battery of my Oura was failing again so I tried Whoop for a year. Tracking Submission Grappling with a device in my underwear is so next level!

Charging the devices got annoying; I found the Garmin Instinct you only charge once a month and it comes with a flashlight.

Seriously, you haven’t truly lived before you’ve had a flashlight on your watch—it’s glorious. (Jokes aside, it’s awesome.)

I was feeling down and I needed something to help me get back to prioritizing fitness and health, and I just happened to check back on Whoop right when Whoop MG was released…

So I threw financial responsibility out the window and ordered the MG on release day.

Gosh, I’ve been good about not changing my phone that often but I didn’t realize I’ve wasted so much time and money on smart devices…

Anyway, I’m currently using the Garmin Instinct 2X Solar as a “don’t glance at my phone” device and Whoop as my fitness and health tracker, ignoring the Garmin’s health and fitness tracking features.

The point is to change behaviours

While it’s fun to play with new things, the main point of these smart devices is to (hopefully) help me change my behaviours in a positive way. It doesn’t matter if the device is super accurate or produces the most beautiful graphs God has created; if it doesn’t help me do things differently then the device is a failure.

Here are two examples:

  1. My first Fitbit smartband was great.

    It’s true that it was quite limited in what it could do, the tracking of steps/calories/steps/etc was wildly inaccurate, and it disconnected from my phone all the time making notifications completely unreliable.

    However, it presented the daily steps/calories/steps as pretty bars on the screen—bars that I should fill over the course of the day—and that caused me to move more than I’d have done otherwise.

    (This is a feature in most smartwatches today but nothing I’ve tried captured the feeling as well as my very first Fitbit device.)

  2. The Garmin Instinct is useful, but not as a fitness or health device.

    I think the watch is very nice; it’s got a fantastic battery life, it’s not bothering me when I sleep, I don’t have to reach for my phone to see if I have any notifications, and the flashlight is amazing.

    It [the flashlight] is just super effin’ functional and useful day-to-day, with just as much utility as the flashlight on your phone (if not more, since your hands are still free).

    The Garmin also has lots of various measurements (and with much more accuracy than my old Fitbit); I can see the daily steps; I can follow how my “body battery” is doing; I can track walks or runs very well (although I don’t run); and I can follow my stress and heart rate throughout the day…

    But that doesn’t do anything for me. I have all these bars and graphs and circles on the watch that I should fill to meet my daily step count for example… But I just don’t. Maybe my neanderthal brain needs more colors or something.

    The Garmin app is also not designed in a way to help me get motivated. Maybe it’s more geared towards runners or athletes that already train hard every day, instead of middle-aged programmers who need that extra push to get out there?

    For whatever reason, as a device to improve my fitness and health, the Garmin is a failure.

Why I left Whoop last year

About a year ago I chose to not renew my Whoop subscription and I made a post on Reddit explaining why.

Here’s a quick summary of my issues with Whoop:

  • If I had the Whoop in my boxers it thinks I’m sleeping when I sit in front of the computer or lie in the sofa.
    (This is still a big issue.)

  • No custom insights for the journal.
    (Still nothing.)

  • The UI for the strength trainer portion of the app sucked.
    (You can now add exercises after the training—a big step forward.)

  • It’s too expensive if all it provides is tracking for Submission Grappling.
    (I realized I’m missing some of the other stuff.)

What made me change my mind?

When I quit Whoop I was in a pretty good place in my life; I was feeling good, I was training consistently, and I didn’t need external stimuli to keep going.

But things have been different recently. I’ve been struggling with depression, haven’t been able to get back to a regular training routine, and I feel that I need all help I can get to get back on track.

During the 10 months without Whoop I relied on my Garmin watch and I realized that the watch simply wasn’t helping me to improve my fitness or health the way I wanted from a smart device.

I looked at alternatives but in the end I couldn’t find an alternative that matched Whoop’s feature set, so here I am.

Whoop screwed over their customers

It was an interesting feeling to visit r/whoop after having placed my order and see it overrun by people extremely upset about how Whoop was treating their existing subscribers.

I agree that Whoop displayed some real corporate bullshit by telling people to pay an upgrade fee despite claiming for years that upgrades would be free (then walking back on the upgrade fee somewhat), claiming that the 6-month subscription requirement for new hardware was “a mistake on their blog”, and by breaking compatibility with the 4.0 bands.

Some people claim that Whoop broke the band compatibility intentionally but as I’ve experienced the same at work I think incompetence is more likely.

I don’t want to be constrained by the old design!

Programmer, before corporate disaster

It sucks but corporations screwing us over in various ways is more or less expected. This whole situation made me regret resubscribing to Whoop immediately, before letting the dust settle.

Thoughts on Whoop in 2025

Here are my first impressions after using the Whoop MG for almost a month after resubscribing.

Battery life is excellent

Some people don’t mind charging their devices a few times a week but I personally loath it. With the new Whoop and its battery life of ~2 weeks I can finally leave the battery pack when I go on my one week work trips and it’s super nice.

The app UI is great

Maybe it’s “just” pretty UI but I find Whoop’s presentation much more helpful than the rawer presentation that Garmin has.

For example I’ve always liked to look at sleep information ever since I started wearing an Oura ring and Whoop’s (recently redesigned) presentation is very good as it focuses on actionable metrics:

Whoop to the left focuses on metrics that I can change while Garmin to the right focuses on sleep stages that I have no idea how to influence.

I also think Whoop is immediately more useful than Garmin’s when you open it up:

Whoop’s landing page to the left and Garmin’s on the right.

I’m a sucker for pretty graphs and Whoop has a lot of them.

I have some gripes with the UI—I would like to be able to customize the home screen more for example—but overall I think Whoop’s app is a lot better designed and more useful than any alternative I’ve tried.

Whoop in the boxers

As I mentioned before, Whoop still thinks that I’m sleeping when I sit with Whoop in the boxers. Either before I go to bed, after I go to bed, and sometimes it thinks I’m napping when I’m sitting during the day.

Very annoying.

But they’re still a killer feature for me because it’s the best way to track Submission Grappling. Whoop is less accurate than the Polar bicep/chest straps but a biceps band or a chest strap sometimes gets in the way during training, while I’ve never noticed the Whoop in my boxers.

Submission grappling. A version of hugging where you try to strangle each other.

The Whoop 5.0 / MG also fits the 4.0 boxers well—lucky me as I have a bunch of the older 4.0 boxers. I’ve gotta admit, I like their boxers and I kept using them even after I canceled my subscription.

The Whoop MG in the 4.0 boxers to the left and the MG in the new boxers to the right.

I was skeptical to their new design with the “pods” but I think they’re an improvement as it’s a lot easier to add/remove the device while wearing the boxers, and if you remove the pod they’re just like regular boxers. The holders on my old 4.0 boxers have started to peel away and started to chafe but I think there’s less of a risk with the new design.

ECG

The ECG feature is why I went with Whoop MG instead of Whoop 5.0.

Outputs from Whoop’s ECG feature. It’s a little difficult to take an ECG as you need to stay very still—you can see where I moved too much in the top-right picture.

I’ve had a couple of episodes with chest pain where I’ve hurried to the hospital to get a check-up. They never showed anything out of the ordinary (it was ruled as “something muscular”) but the experience has left me worried.

If the ECG and background “Irregular Heart Rhytm” detection makes me relax a little (or if they do detect something) then I figured it’s worth the price jump from the 5.0 to the MG.

Whoop age

My “Whoop age” right after it unlocked after 3 weeks. You can see that I’ve been exercising way to little and that I’m slightly overweight.

I’ve seen Reddit warriors call the Whoop age metric a “gimmick” and dismiss it as just a combination of metrics available elsewhere.

But for me—a middle-aged programmer dad who’s struggling to exercise consistently—the Whoop age metric is a fantastic addition to Whoop as it gives me a pretty and actionable way to improve my overall health.

I already know that I need to lose weight and exercise more, but sometimes you need someone else to hit you with the reality before you internalize the problem and start doing something about it.

It’s too early to tell how useful the Whoop age metric ultimately ends up being but my first impression is very positive.

More measures

Whoop also comes with more “raw” measures that I glance at from time to time:

  • Body weight and body composition

    Whoop now syncs with my Withings smart scale and I do need to lose weight; it’s nice to have it in the Whoop app so I don’t have to open up Withing’s app just to see my weight trend.

  • Steps

    Again, I have (more accurate) step tracking in Garmin but it’s nice to have it all in one app. I try to hit a reasonable step goal and step counting—even if inaccurate—has made me try to move around more.

  • VO2 MAX

    It seems like an important metric but I’m not sure about it’s accuracy and it hasn’t caused me to change any behaviours. (I also need to calibrate it with a 15 minutes run. I haven’t been on a run the last decade…)

  • Blood pressure

    I calibrated the blood pressure insights but I’m not sure how useful it is for me. Maybe it can act as an early indicator that my health is deteriorating but so far it’s just a pretty statistic.

Journaling and insights

The journal that correlates your behaviours with your recovery scores is an amazing feature and in theory this feature alone could carry a device such as Whoop by itself…

If it wasn’t for the fact that the usefulness tapers off hard as soon as you learn how the various behaviours affect your body. At the end of my last subscription I even turned off the journal as it didn’t provide any new information and filling it in only became a chore.

Right now I use it but I only track a handful of behaviours that I want to remind myself of. However, I’ll probably turn off the journal in a few months when the newness wears off again.

Bands

It was a really shitty move by Whoop to break backwards compatibility with the old bands. Granted, I don’t care that much about appearance so I never bought a lot of bands but I still want to switch between bands when they get wet after shower.

Where corporations fails the 3D printer community steps up to save the day. You can 3D print a whoop 5.0 / MG adapter to continue using your existing bands:

A 4.0 band together with my Whoop MG.
A 3D printed adapter making the 4.0 clasp compatible with a 5.0/MG device.

It’s not as secure as a proper 5.0/MG band and the old 4.0 battery doesn’t fit but it works well enough for regular usage (I don’t dare to swim with it).

I’ll probably buy another band anyway so I have another ECG-compatible clasp; possibly a colorful SportFlex band for when I swim with the kids.

Pricing

Whoop outdid themselves and the MG took something already expensive and made it even more expensive. While I don’t regret going with the more expensive option, the “Peak” plan (without Blood pressure and ECG) probably makes more sense for most people.

Still, if Whoop can help me improve my fitness or health—even in a small way—the cost is worth it for me.

Conclusion

Despite its faults and corporate bullshit, the Whoop is still my favorite smart device for fitness and health. I’ve tried to find alternatives but nothing has been as convenient or as helpful (or as expensive) as the Whoop.

But the jury is still out on how helpful Whoop will be for me this time and in a year I may have moved on to try something else.

Some VORON 0 mods

2025-05-02 08:00:00

I recently completed my VORON 0 build and I was determined to leave it as-is for a while and to start modding my VORON Trident

So before embarking om my larger Trident modding journey I decided to work on the VORON 0 just a little bit more.

HEPA filter

With the Nevermore Micro V4 I had active carbon filtering but I also wanted a HEPA filter that would also provide negative air pressure to the printer. I found the Hepa filter by JNP for the VORON 0.1 and a mount for the VORON 0.2 that I installed.

For the fans I used two Noctua NF-A4x10 FLX fans and I spliced them together with the Nevermore filter, allowing the MCU to control all the filter fans together. It might have been better to buy the 5V versions and connect them to the 5V output to have them always on, but by then I had already ordered the other version. Oh well.

Back meshed panel

The small 5V fan for the Raspberry Pi was super loud and I wanted to replace it with something. Because the Raspberry Pi Zero doesn’t get that hot I removed the fan and replaced the back panel with a meshed variant, which I hope should provide enough airflow to keep the electronics cool.

(There are other variants with integrated fans if I realize this wasn’t enough.)

Modesty mesh

The wiring is super ugly and I stumbled upon the modesty mesh that hides the wires well from the sides. Not at all necessary but they make the printer a little prettier.

Full size panels

One thing that bothered me with the stock VORON 0.2 was the gaps between the tophat and the side panels and front door. I went looking for a mod with fill-sized panels and found the ZeroPanels mod.

Instead of magnets the printed parts clips into the extrusions pretty hard while still allowing you to pull them off when you want to. It works really well honestly.

The clips were slightly difficult to print but manageable.

I was looking at the BoxZero mod for a proper full-sized panels mod but I didn’t want to tear apart the printer and rebuild the belt path so I simply replaced the stock panels with full sized ones. This does leave some air gaps at the back and front of the printer right next to the belt that I simply covered with some tape:

Some tape to cover the gaps around the belts.

While the clips are good for panels you don’t remove that often, they’re too much to use for the front door. They have some magnetic clips you can use but I’m honestly perplexed on how to use them for good effect.

The standard VORON 0 handles don’t consider the extra 3mm the foam tape adds, leaving a gap that severely reduces the pulling force of the magnets. Similarly the magnet clips included in ZeroPanels surprisingly have the same issue.

For the door handle I used the stealth handle found in the Voron 0.2 fullsize ZeroPanel mod that does take the foam tape into consideration.

Three different magnet holders; at the top the Stealth handles holders that come out 3mm, in the middle the 6mm holder, and at the bottom the standard magnet holder.

There’s a variant of the clips for 6mm magnets in the pull requests that I used by pushing in two 3x2mm magnets and super gluing one 10x3mm magnet on top, so it sticks out the 3mm extra distance the foam tape adds. (Yes, maybe just the 10x3mm magnet would be enough).

For the outside I used the standard ZeroPanels holders for 10x3mm magnets, allowing the magnets close really tightly against each other.

Extra magnets at the top of the printer to get a proper seal.

Let's build a VORON 0

2025-03-25 08:00:00

About 1.5 years ago I ventured into 3D printing by building a VORON Trident. It was a very fun project and I’ve even used the printer quite a bit.

Naturally, I had to build another one and this time I opted for the cute VORON 0.

Why another printer?

I really like my VORON Trident and it’ll continue to be my main printer for the foreseeable future but a second printer would do two important things for me:

  1. Act as a backup printer if my Trident breaks.

    A printer made partially of printed parts is great as you can easily repair it… But only if you have a working printer to print the parts.

    It would also be very annoying if I disassemble the printer because I want to mod it and realize I’ve forgotten to print a part I needed.

  2. Building printers are really fun.

    Building the VORON Trident is one of my most fun and rewarding projects I’ve done.

Why a VORON 0?

These properties makes the VORON 0 an ideal secondary printer for me:

  1. You need to assemble the VORON 0 yourself (a feature not a bug)

  2. Prints ABS/ASA well (for printer parts)

  3. Very moddable and truly open source

  4. It’s tiny

    The VORON 0 to the left and the VORON Trident 250 to the right. It’s really small, which is perfect for me as I have a limited amount of space.

    It would be very fun to build a VORON 2.4 (or even a VORON Phoenix) but I really don’t have space for more printers.

Getting the parts

I opted to buy a kit instead of self-sourcing the parts as it’s usually cheaper and requires a lot less work, even if you replace some parts.

This is what I ended up getting:

  • A VORON 0 kit from Lecktor

  • Parts for a Dragon Burner toolhead

  • Parts for a Nevermore V4 active carbon filter

  • Later on, I replaced the SKR Mini E3 V2 that came with the kit with the V3

Lots of delays

I ordered a VORON 0 from Lecktor in February 2024 and it took roughly 4 months before I got the first shipment of parts and it wasn’t until the end of 2024 that I had received all the parts needed to complete the build.

The wait was annoying

Printing parts myself

So what do you do when you can’t start the build?

You print parts!

A box of some of the printed parts for the build (and many I later threw away).

There’s something very satisfying with printing parts you then build a printer with.

This time I wanted to make a colorful printer and I came up with this mix of filament:

I think they made the printer look great.

The build

I won’t do as detailed of a build log as I did when building the VORON Trident but I tried to take some pictures. Scroll on!

Frames and bed

The linear Y-rails.
The kit comes with the Kirigami bed mod.
The frame with A/B motors.
Building the bottom of the printer with feet, power supply, and display.

MGN9 instead of MGN7 X-axis

After I assembled the X-axis I noticed a problem:

The carriage collides with the stock A drive.

The reason is that the kit comes with MGN9 rails for the X-axis instead of the standard MGN7 rails. This required me to reprint modified A/B drives, X-carriage, and alignment tools.

The carriage passes the modded B drive.

Belts

Starting to install the belt.
The belt is tight.

Dragon Burner toolhead

I got the parts needed to build the standard mini stealthburner…

But I’m attracted to playing around with new stuff and I decided to try out the Dragon Burner instead. I went with it because it’s quite popular, it has good cooling (I print a bunch of PLA), and I haven’t tried it out yet.

The fans are inserted. I don’t care about LEDs so I inserted an opaque magenta part instead. I think it looks really good.
The back of the Dragon Burner. I opted for the Rapido 2 instead of the Dragon that came with the kit because the Dragon has problems printing PLA.
I was a bit confused on how to route the wires as there was very little space when mounting the toolhead on the carriage. Routing the wires close to the fans, clipping off the ears of the fans, and holding together it with cable ties in this way worked for me.

Galileo 2 standalone

Dragon Burner together with the Galileo 2 extruder mounted on the printer.

For the extruder I opted for the standalone version of Galileo 2. I’ve used Galileo 2 on the Trident but I hated the push down latch it uses in the Stealthburner configuration. The latch eventually broke by pulling out a heat-set insert so I went back to the Clockwork 2 on the Trident, giving me the parts to rebuilt the Galileo for the VORON 0 in a standalone configuration.

The parts for Galileo 2. There will be left-overs from the Stealthburner variant.

The build was really fast and simple—compared to the Stealthburner variant it’s night and day. I didn’t even think to take a break for pictures.

Nevermore filter

Since I want to be able to print ABS I feel I need to have an activated carbon filter. I wanted to have an exhaust fan with a HEPA filter as well, but I’ll leave that to a mod in the future.

The Nevermore V4 is an activated carbon filter that fits well in the VORON 0.

I fastened the fan using a strip of VHB—it was a struggle to position it in the middle.
The Nevermore is mounted standing in the side of the printer.

Panels

With the panel and spool holder at the back.
With the tophat and door installed.

I’m slightly annoyed with the small gaps and holes the printer has (mainly between the tophat and the panels at the bottom half).

Wiring

Wiring was simpler than for the Trident but it was harder to make the wiring pretty. Thank god I could cover it up.

The underside of the printer with the power, 5V converter, display, and Z-motor.
Back of the printer with the Raspberry Pi and MCU.

Raspberry Pi

The Raspberry Pi only has two cables; power and communication over the GPIO pins and a display via USB.
The Pi communicates and gets power over the TFT connection on the MCU.

Toolhead

The kit came with a toolhead board and breakout board for an umbilical setup:

The toolhead board.
I didn’t use the x-endstop.
The breakout board.

I did run into an issue where the polarity of the fans on the toolhead board did not match the polarity of the fans on the MCU, leading to some frustration where the fans refused to spin. I ended up swapping the polarity using the cables from the breakout board to the MCU.

Chamber thermistor

The MCU only has two thermistor ports and they’re used for the hotend and bed thermistors. For the chamber thermistor (that’s integrated into the breakout board) I use the MOSI pin on the SPI1 8-pin header:

The chamber thermistor connected to MOSI and ground on the SPI1 header.

SKR mini E3 v3

I got an SKR mini E3 v2 with the kit but I replaced it with the v3 for two reasons:

  1. An extra FAN output, used for the Nevermore Filter
  2. A filament runout sensor

There’s not much to say about the extra FAN output but the filament runout sensor has 3 pins, while VORON 0.2 style runout sensor has 3 pins. I reused the prepared y-endstop I got with the kit, scratched away some of the plastic to make the 2-pin connection fit the 3-pins on the MCU (the +5V pin isn’t needed):

The filament runout sensor connected to E0-stop.
Yes it’s not the right connector but where there’s a will there’s a way.

Klipper setup

I followed the VORON documentation and chose Mainsail as I’ve been happy with it on my Trident. I’m not going to describe everything and only call out some issues I had or extra steps I had to take.

MCU firmware

The VORON documentation assumes USB communication so the default firmware instructions didn’t work for me.

According to BigTreeTech’s documentation if you communicate over USART2 (the TFT port) then you need to compile the firmware with Communication interface set to Serial (on USART2 PA3/PA2). You then need to use this klipper configuration:

[mcu]
serial: /dev/ttyAMA0
restart_method: command

Filament runout

[filament_switch_sensor Filament_Runout_Sensor]
pause_on_runout: True
runout_gcode: PAUSE
switch_pin: PC15

Chamber thermistor

According to this comment this is the config to use the SPI header for a thermistor:

[temperature_sensor chamber_temp]
sensor_type: Generic 3950
sensor_pin: PA7
pullup_resistor: 10000

Works for me™

Display

It’s easy to flash the display directly from the Raspberry Pi although the first firmware I built was too large. There are optional features you can remove but I removed too many so the configuration for the buttons wasn’t accepted. These were the features that ended up working for me:

[*] Support GPIO "bit-banging" devices
[*] Support LCD devices
[ ] Support thermocouple MAX sensors
[ ] Support adxl accelerometers
[ ] Support lis2dw and lis3dh 3-axis accelerometers
[ ] Support MPU accelerometers
[*] Support HX711 and HX717 ADC chips
[ ] Support ADS 1220 ADC chip
[ ] Support ldc1612 eddy current sensor
[ ] Support angle sensors
[*] Support software based I2C "bit-banging"
[*] Support software based SPI "bit-banging"

Sensorless homing

I was nervous setting up sensorless homing, fearing that without a physical switch the printer might decide to burn the motor against the edge or something. (I really have no idea how it works, hence my fear.)

In the end it was straightforward. The VORON 0 example firmware was already configured for sensorless homing and the only things I had to do was:

  1. Physically jump the X-DIAG and Y-DIAG pins on the board
  2. Tweak the driver_SGTHRS values (I landed on 85 down from 255)

And now I have sensorless homing working consistently.

What confused me was that the sensorless homing guide and the homing macros it links to were slightly different from the VORON 0 example firmware and it wasn’t clear if I had to make all the changes or not. (I did not.)

Some random issues I encountered

In typical 3D printer fashion, you’ll always run into various issues, for example:

  • I got the mcu shutdown: Timer too close error a few times.

    I don’t know what I did but it only happened a couple of times at beginning.

  • The filament sensor had some consistency issues.

    Some extra tape on the bearing seemed to fix it.

  • The filament keeps getting stuck in the extruder after unload.

    I’m still having issues but forgetting to tighten the nozzle and using a too short PTFE tube didn’t help.

  • I had trouble getting the filament to stick to bed.

    Super frustrating to be honest. I re-calibrated the z offset and thumb screws a bunch of times and (right now) it seems to work fairly well. Even though you’re not supposed to need automatic bed leveling for a printer this small, I can’t help but miss the “just works” feeling I have with the Trident.

Initial thoughts on the printer

A model I printed for one of my kids. It came out really well.

I haven’t printed that much with the printer yet but I have some positive things to say about it:

  • It looks very good and was very fun to build.
  • The small footprint is great and a surprising amount of prints fits on the small build volume.
  • The print quality is very good. The extra cooling from the Dragon Burner is great when printing PLA (which I use a lot).

But I have some negative things to say too:

  • It’s loud. The 5V fan close to the Raspberry Pi is horribly loud but the print movement is also too loud for my taste.
  • It’s poorly insulated. For example there are gaps between the top hat and the rest of the printer that I don’t see a good way to cover up.

Overall though I’m very happy with it. I wouldn’t recommend it as a first printer or to someone who just wants a tool that works out of the box, but for people like me who wanted to build a backup/secondary printer I think it’s great.

What’s next?

With a secondary printer finally up and running I can now start working on some significant mods for my Trident! This is the tentative plan right now:

  1. Install the Inverted electronics mod.
  2. Replace Stealthburner with another toolhead, most likely A4T-toolhead.
  3. Build a BoxTurtle for multi-color support.

But we’ll see when I manage to get to it. I’m not in a rush and I should take a little break and play with my VORON 0 and perhaps work on my other dozen or so projects that lie dormant.