MoreRSS

site iconJonas Hietala

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

Why I don't rely on AI for programming (too much)

2024-10-31 08:00:00

I find that ai can help significantly with doing plumbing, but it has no problems with connecting the pipes wrong. I need to double and triple check the updated code - or fix the resulting errors when I don’t do that.

I’ve been skeptical of the AI craze that’s been going on in the developer community. It’s a useful tool but some people behave like large swaths of developers will be replaced by AI tomorrow.

I don’t understand the hype as my experience has been quite different, yet I’ve struggled to pinpoint why. In this post post I’ll try to explain what I think is the fundamental problem I have with letting an AI generate code for me.

I’m bad at double-checking code

I realized what my problem with AI is when I read this comment on Hacker News (emphasis mine):

My theory is the willingness to baby sit and the modality. I’m perfectly fine telling the tool I use its errors and working side by side with it like it was another person. At the end of the day it can belt out lines of code faster than I, or any human, can and I can review code very quickly so the overall productivity boost has been great.

It’s true that I’m not fond of pair programming but the key issue is that I can’t review code quickly. On the contrary I’m quite bad at looking at an unknown piece of code and verify that it’s correct.

Struggling to verify math problems

This isn’t a problem of mine that’s unique for programming. I’ve been quite good at math (relatively speaking) since I was a child and I breezed through the University math (where I read as many math courses I could get my hands on).

Despite my relative skills I always got marks against me during tests and exams. They weren’t caused by my lack of understanding but by small mistakes like writing numbers wrong. Mistakes that I tried hard to correct; I started to double-check and triple-check my work but they were still slipping through.

I realized that when I was first solving the problem I was focused. I was in the zone and I could keep the problem in my head while I worked.

But when I went back to verify my work my brain wouldn’t engage in the same way. I was trying to but I couldn’t get into the zone. The problem was Done™ and it was like my brain had disengaged. If I was looking at myself in a third-person view I’m sure my eyes would glaze over.

It’s hard to read code without a mental model

When we write code or solve math problems I think we build up a mental model of the problem we’re trying to solve and the system we’re interacting with; what a variable name signifies, what effects a function call might have, and how pieces of information relate to one another.

This mental model is crucial when reading code or solving math problems and if it’s missing we need to rebuild it. I think this is what happened when I had finished my math problems: when I was finished I dropped the model, so coming back to it was a struggle.

The same is true when reviewing code; you’ll be much more effective when reviewing small changes to a code base you’re familiar with because you already have a mental model of the surrounding systems. It becomes harder when you’re reviewing larger changes, or reviewing changes in an unfamiliar code base, because you have more gaps in your mental model.

I struggle to properly review code

Maybe it’s a skill issue but I find it much more difficult to find errors in code others write (or I myself wrote a while ago) than to find errors while I’m developing the code. I get the same “eye glazes over” feeling as when I went back to verify my math problems. I’m slow, I know I’m not doing a good job, and it’s a struggle.

I truly wonder how other people review code in a productive way. Sometimes I feel I need to run, change, and test the code to understand it… But that’s time consuming especially as the amount of code increases. Trusting your fellow developers seems like a necessity.

AI generated code requires careful verification

Some are enamored with how great AI code generation is. And to be sure, compared to just a few years ago it’s unbelievably good. But would I trust the code as much as I’d trust a co-worker? Absolutely not.

In my experience an AI is at best as good as a new developer, often much worse, and sometimes outright horrible. (And no, I don’t blindly trust a new developer. I don’t trust myself either.) At least I can be reasonably sure that other developers test or run their code before I need to look at it.

Relying on AI is like copy pasting code from Stack Overflow: useful but you cannot trust it. While the code may look good on a surface level, it’s often subtly wrong in ways that even a Stack Overflow answer doesn’t quite manage to. Hallucinating a non existing library function or adding an extra argument are quite common.

This is mostly fine for short snippets where it’s easy to run the code and test but the problem becomes significant when you copy paste rely on AI for larger pieces of code.

Programming less and reviewing more is a bad trade

The crux of the matter is that I’m much more productive when I’m programming than when I’m reviewing code. With most current AI tools it feels like I’m reviewing code more than programming and that’s a bad trade for me to make.

You still need to build a mental model

While you’re writing code you’re continually building up your mental model but when you let an AI generate the code you still need to do the hard work of building your mental model.

I don’t think writing code is the most important thing you’re doing while programming—it’s building a mental model of the system you’re building.

There’s no return of your investment

Ever felt that it would be faster to just code something yourself than to gently guide a junior developer through a problem? That’s how I feel like when I shepherd an AI, with the difference that teaching a junior programmer is an investment but the AI won’t learn no matter how many times you interact with it.

Some AI tools are very useful

I need to clarify that while I’m skeptical towards the current AI hype I find some AI tools useful in various contexts.

For programming I’m a heavy user of Kagi’s quick answer functionality that uses AI to summarize the search results and gives you references so you can drill down further if you need to. I use it many times a day to answer questions like:

  • How do you format a date in Python?
  • How do you subscribe to a table change in postgres in Elixir?
  • How do you open a new buffer in Neovim using Lua?
  • How to order mp4 by length in Linux?
  • What’s the cron syntax to execute a script every second day?

It’s not bullet proof but the combination of good search results (way better than Google in my opinion) combined with AI’s summarizing ability is absolutely fantastic.

I must be missing something

AI dev tools are useful, I just haven’t seen the incredible productivity boost that some say exist. Maybe they are working on different problems in different contexts than I am, have different standards, or just are better at utilizing them than I am?

Because, surely, it would be way to simple to dismiss the productivity claims as people evaluating the tools as how useful they may become instead of how useful they are right now?

xkcd: 605; ever relevant as a response to the currently hyped technology.

Writing Home Assistant automations using Genservers in Elixir

2024-10-08 08:00:00

I’ve been a fan of Home Assistant a while now; it’s a great platform for home automation with its beginner friendly and feature rich UI, support for a ton of different devices and integrations, and there’s a bunch of ways to create automations.

But there’s no engine for writing automations in Elixir that I could find; this post addresses this fatal weakness.

Specifically, in this post I’ll go through:

  1. How to use Home Assistant’s Websocket API.
  2. An introduction to GenServers and concurrency in Elixir.
  3. How to use this knowledge to write and test a simple automation.

Why Elixir?

Ever since I started with home automation I’ve thought that it would be a great match for the concurrency model that Elixir uses. You’ll have all sorts of automations running concurrently, reacting to different triggers, waiting for different actions, and interacting with each other; something I think Elixir excels at.

Now, there are many options for writing automations for Home Assistant that already work well, the biggest reason I wanted to use Elixir is because I like it. That Elixir happens to be a good fit for home automation is just a bonus.

I’ve tried to write automations via the Home Assistant UI (meh), using YAML configuration (hated it), visual programming with Node-RED (I want real programming), and in Python using Pyscript (pretty good). In the end I simply enjoyed writing automations in Elixir more.

Controlling Home Assistant from Elixir

The very first thing we need to solve is how do we get data from Home Assistant and how to call services (now called actions)?

Home Assistant has a websocket API and a REST API that we can use to implement our engine. As we can get entity states and call services over the websocket there’s no need to bother with the REST API for our example.

Connecting

I used WebSockex to setup the websocket connection to Home Assistant. Here’s a tentative start that connects and receives a message:

defmodule Haex.WebsocketClient do
use WebSockex
require Logger
# Adjust to your Home Assitant instance
@url "ws://lannisport:8123/api/websocket"
def start_link(_args) do
WebSockex.start_link(@url, __MODULE__, %{}, name: __MODULE__)
end
@impl true
def handle_frame({:text, msg}, state) do
case Jason.decode(msg) do
{:ok, msg} ->
Logger.debug("Received:\n#{inspect(msg)}")
handle_msg(msg, state)
{:error, error} ->
Logger.warning("Couldn't decode message `#{inspect(error)}`:\n#{inspect(msg)}")
{:ok, state}
end
end
defp handle_msg(msg, state) do
Logger.warning("Unhandled message: #{inspect(msg)}")
{:ok, state}
end
end

As with all concurrent services in Elixir Websockex should be started in a supervision tree. Under the main Application Supervisor works well:

defmodule Haex.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [Haex.WebsocketClient]
Supervisor.start_link(children, strategy: :one_for_one)
end
end

If we run this then Home Assistant will send us a message upon connection:

[warning] Unhandled message: %{"ha_version" => "2024.10.0", "type" => "auth_required"}

This means we need to authenticate using a long lived access token. Reading the websocket API we should respond with an "auth" message:

defp handle_msg(%{"type" => "auth_required"}, state) do
token = Application.fetch_env!(:haex, :access_token)
reply =
Jason.encode!(%{
type: "auth",
access_token: token
})
{:reply, {:text, reply}, state}
end

It’s prudent to fetch secrets from environment variables in runtime.exs:

config :haex, access_token: System.fetch_env!("HA_ACCESS_TOKEN")

And now we get another unhandled message, telling us our auth succeeded:

[warning] Unhandled message: %{"ha_version" => "2024.10.0", "type" => "auth_ok"}

Subscribing to state changes

After authenticating we can tell Home Assistant that we’d like to subscribe to all state changes in the system (so we can write automations that trigger on a state change).

I’m lazy so I send the subscription message when I’m handling (ignoring) the "auth_ok" message:

defp handle_msg(%{"type" => "auth_ok"}, state) do
reply = Jason.encode!(%{id: 1, type: :subscribe_events, event_type: :state_changed})
{:reply, {:text, reply}, state}
end

With this up we’ll get another acknowledgment that our subscribe command succeeded (matching id: 1):

[warning] Unhandled message: %{"id" => 1, "result" => nil, "success" => true, "type" => "result"}

And we start receiving state changed messages:

[warning] Unhandled message: %{"event" => %{"context" => %{"id" => "01J9DK3CN0CEEWGCV1139HTC11", "parent_id" => nil, "user_id" => nil}, "data" => %{"entity_id" => "sensor.vardagsrum_innelampor_switch_power", "new_state" => %{"attributes" => %{"device_class" => "power", "friendly_name" => "Vardagsrum innelampor switch Power", "state_class" => "measurement", "unit_of_measurement" => "W"}, "context" => %{"id" => "01J9DK3CN0CEEWGCV1139HTC11", "parent_id" => nil, "user_id" => nil}, "entity_id" => "sensor.vardagsrum_innelampor_switch_power", "last_changed" => "2024-10-05T05:40:36.640422+00:00", "last_reported" => "2024-10-05T05:40:36.640422+00:00", "last_updated" => "2024-10-05T05:40:36.640422+00:00", "state" => "4.6"}, "old_state" => %{"attributes" => %{"device_class" => "power", "friendly_name" => "Vardagsrum innelampor switch Power", "state_class" => "measurement", "unit_of_measurement" => "W"}, "context" => %{"id" => "01J9DK37CMJBDFK7M5VGYJ1CZG", "parent_id" => nil, "user_id" => nil}, "entity_id" => "sensor.vardagsrum_innelampor_switch_power", "last_changed" => "2024-10-05T05:40:31.252863+00:00", "last_reported" => "2024-10-05T05:40:31.252863+00:00", "last_updated" => "2024-10-05T05:40:31.252863+00:00", "state" => "4.5"}}, "event_type" => "state_changed", "origin" => "LOCAL", "time_fired" => "2024-10-05T05:40:36.640422+00:00"}, "id" => 1, "type" => "event"}
[warning] Unhandled message: %{"event" => %{"context" => %{"id" => "01J9DK3CQ27BWBX0R9MAP5SRM9", "parent_id" => nil, "user_id" => nil}, "data" => %{"entity_id" => "sensor.dishwasher_plug_voltage", "new_state" => %{"attributes" => %{"device_class" => "voltage", "friendly_name" => "Dishwasher plug Voltage", "state_class" => "measurement", "unit_of_measurement" => "V"}, "context" => %{"id" => "01J9DK3CQ27BWBX0R9MAP5SRM9", "parent_id" => nil, "user_id" => nil}, "entity_id" => "sensor.dishwasher_plug_voltage", "last_changed" => "2024-10-05T05:40:36.706679+00:00", "last_reported" => "2024-10-05T05:40:36.706679+00:00", "last_updated" => "2024-10-05T05:40:36.706679+00:00", "state" => "232.5"}, "old_state" => %{"attributes" => %{"device_class" => "voltage", "friendly_name" => "Dishwasher plug Voltage", "state_class" => "measurement", "unit_of_measurement" => "V"}, "context" => %{"id" => "01J9DK37THDW13GTP09KXNMG0Q", "parent_id" => nil, "user_id" => nil}, "entity_id" => "sensor.dishwasher_plug_voltage", "last_changed" => "2024-10-05T05:40:31.697304+00:00", "last_reported" => "2024-10-05T05:40:31.697304+00:00", "last_updated" => "2024-10-05T05:40:31.697304+00:00", "state" => "232.18"}}, "event_type" => "state_changed", "origin" => "LOCAL", "time_fired" => "2024-10-05T05:40:36.706679+00:00"}, "id" => 1, "type" => "event"}
...

Managing cross-service messages with PubSub

At this point I’d like to take a step and plan ahead a little. We have our state changed events but how should we send them to the automations we’ll write?

One option might be to let WebSocketClient loop over all automations and call them directly:

defp handle_msg(msg = %{"type" => "event"}, state) do
for automation <- automations do
automation.state_changed(msg)
end
{:ok, state}
end

But that’s not very flexible. We’d have to keep the automations list updated and what about other services that might want to subscribe to state changes but aren’t automations?

Instead I opted to use Phoenix.PubSub, a publisher/subscriber service that can broadcast messages throughout your application.

First we’ll need to start an instance in our supervision tree (called Haex.PubSub):

@impl true
def start(_type, _args) do
children =
[
{Phoenix.PubSub, name: Haex.PubSub},
Haex.WebsocketClient
]
Supervisor.start_link(children, strategy: :one_for_one)
end

Then we can broadcast messages to anyone who cares to listen:

defp handle_msg(%{"type" => "event", "event" => event}, state) do
Phoenix.PubSub.broadcast(
Haex.PubSub,
"state_schanged",
{:state_changed,
%{
entity_id: event["entity_id"],
new_state: event["new_state"],
old_state: event["old_state"]
}}
)
{:ok, state}
end

If a service wants to receive the messages they’ll subscribe to the "state_changed" channel:

Phoenix.PubSub.subscribe(Haex.PubSub, "state_changed")

Calling services

There’s key component left and that’s how do call a service / execute an action?

You call a service by sending this type of message over the websocket:

# This message turns on a light.
%{
id: 2,
type: :call_service,
domain: :light,
service: :turn_on,
target: %{
entity_id: "light.j_kontor_dator_ledstrip"
}
service_data: %{
color_name: "beige",
brightness: 100
}
}

You’ll then receive a successful result message corresponding to the id of the message. You’re supposed to correlate the ids of the messages you send and receive, but it’s not central to this post so I’ll gloss over that implementation detail.

Outline of a GenServer automation

I decided to create automations as regular GenServers that subscribes to triggers and then does stuff. An automation might look like something like this:

defmodule Automations.MyAutomation do
use GenServer
alias Phoenix.PubSub
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(_opts) do
PubSub.subscribe(Haex.PubSub, "time")
{:ok, %{}}
end
@impl true
def handle_info({:time, time}, state) do
# Do something at a specific time
{:noreply, state}
end
end

If you’re unfamiliar with GenServers then the gist is that a GenServer is an isolated process that receives messages and should be started in a supervision tree.

In the above example we subscribe to the "time" channel and then receive a message with the handle_info callback. (The "time" message is generated from a "state_changed" message for the entity sensor.time that’s updated every minute.)

Let there be light

It’s finally time for the ultimate expression of home automation:
controlling a light source.

Gentlemen I am now about to send a signal from this laptop through our local ISP racing down fiber-optic cable at the speed of light to San Francisco, bouncing off a satellite in geosynchronous orbit to Lisbon Portugal where the data packets will be handed off to submerge transatlantic cables terminating in Halifax Nova Scotia, and transferred across the continent via microwave relays back to our ISP and the XM receiver attached to this…

Lamp.

Jokes aside, controlling a light is great because it’s easy to start with (turn on/off), you’ll get to see results in the real world (the light changes color), and you can increase the complexity if you want (create a sunrise alarm, use circadian lighting, flash during a fire alarm, etc).

Time trigger

Let’s ease into an automation by turning on a light on a specific time:

defmodule Automations.BedroomLight do
use GenServer
alias Phoenix.PubSub
alias Haex.Light
# This is the Home Assistant entity I want to control.
@entity "light.jonas_bedroom_lamp"
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(_opts) do
PubSub.subscribe(Haex.PubSub, "time")
{:ok, %{}}
end
@impl true
def handle_info({:time, time}, state) do
# Note that time only ticks every minute so seconds will always be zero.
if time == ~T[06:00:00] do
Light.turn_on(@entity, color_name: "yellow", brightness_pct: 80, transition: 10)
end
{:noreply, state}
end
end

Wake-up lighting

That was easy. Let’s try something bit more interesting: a wake-up sequence.

Specifically I’d like to gradually change the brightness and color of the light from a dim red to a bright, white light.

We could hardcode it with something like this:

def handle_info({:time, time}, state) do
cond do
time == ~T[06:00:00] ->
Light.turn_on(@entity, brightness_pct: 10, color_name: "red", transition: 450)
time == ~T[06:10:00] ->
Light.turn_on(@entity, brightness_pct: 70, color_name: "orange", transition: 450)
time == ~T[06:20:00] ->
Light.turn_on(@entity, brightness_pct: 80, color_name: "gold", transition: 450)
time == ~T[06:30:00] ->
Light.turn_on(@entity, brightness_pct: 100, kelvin: 2700, transition: 450)
true ->
nil
end
{:noreply, state}
end

But that’s not flexible if we for example want the start time to be configurable via the UI in the future. While refactoring it let’s try to implement the transitions using a message passing approach:

@impl true
def handle_info({:time, time}, state) do
if time == ~T[06:00:00] do
send(self(), :transition_sunrise)
{:noreply, Map.put(state, :light_state, {:sunrise, 0})}
else
{:noreply, state}
end
end

At line 3 we’re using send() to send the message :transition_sunrise to ourselves and at line 4 we’re tracking inserting :light_state as {:sunrise, 0}, to let the GenServer keep track of what transition we should perform.

This message is again handled by handle_info:

def handle_info(:transition_sunrise, state = %{light_state: {:sunrise, _}}) do
case set_sunrise_light(state) do
:done ->
# We've reached our last transition.
{:noreply, Map.put(state, :state, :day)}
{:next, next} ->
# We still have transitions left to handle,
# send another :transition_sunrise message after 10 minutes,
# repeating the loop.
Process.send_after(self(), :transition_sunrise, 10 * 60 * 1000)
{:noreply, Map.put(state, :light_state, {:sunrise, next})}
end
end

The function set_sunrise_light sets the light depending on {:sunrise, sunrise_state} and returns :done when we’ve set the last transition. Pay attention to line 10 where we send another :transition_sunrise message but with a delay, continuing the recursion until we’ve set handled all transitions.

I’m not thrilled about the implementation of set_sunrise_light but here it is:

defp set_sunrise_light(%{light_state: {:sunrise, sunrise_state}}) do
transitions =
[
[brightness_pct: 10, color_name: "red", transition: 450],
[brightness_pct: 70, color_name: "orange", transition: 450],
[brightness_pct: 80, color_name: "gold", transition: 450],
[brightness_pct: 100, kelvin: 2700, transition: 450]
]
# Transform the list into a map with index => transition.
# Yes, it's a shoddy imitation of an array.
|> Enum.with_index()
|> Map.new(fn {val, index} -> {index, val} end)
last_state = Enum.count(transitions) - 1
{light_opts, next_transition} =
if sunrise_state >= last_state do
{transitions[last_state], :done}
else
{transitions[sunrise_state], {:next, sunrise_state + 1}}
end
Light.turn_on(@entity, light_opts)
next_transition
end

Abort the wake-up sequence

I’d like to add the ability to abort the sunrise alarm by turning off the lamp. It’s fairly straightforward:

  1. Subscribe to a state change:

    PubSub.subscribe(Haex.PubSub, "state:" <> @entity)

    (I use a simplified message instead of the raw "state_changed" message we’ve seen before.)

  2. Change the state if we’re in a sunrise:

    def handle_info({:state, @entity, "off"}, state = %{state: {:sunrise, _}}) do
    {:noreply, Map.put(state, :state, :day)}
    end
    def handle_info(_, state) do
    {:noreply, state}
    end

We still have a :transition_sunrise message that will arrive later but the fallback handle_info will ignore it. If we’ll implement a snooze or restart for our sunrise this may become a problem.

Refactoring into another GenServer

What we’ve done so far works but the structure isn’t ideal. The leftover :transition_sunrise message bothers me and what if we want to implement another light transition, either for a bedtime routine or for another light? Then we’d have to re-implement a large portion of the automation, which isn’t my idea of fun.

We can break out the code into another GenServer, let’s call it LightTransition, and we can let it keep track of the transitions and lets us focus on the more interesting parts of automation writing.

This lets us start a sunrise with something like this:

if time == ~T[06:00:00] do
{:ok, transition_pid} =
LightTransition.start_link(
entity_id: @entity,
transitions: [
[brightness_pct: 10, color_name: "red", transition: 450],
[brightness_pct: 70, color_name: "orange", transition: 450],
[brightness_pct: 80, color_name: "gold", transition: 450],
[brightness_pct: 100, kelvin: 2700, transition: 450]
]
)
state =
state
|> Map.put(:light_state, :sunrise)
|> Map.put(:transition, transition_pid)
{:noreply, state}

At line 2 we start our transition using start_link, foregoing the supervision tree as it doesn’t make sense to have the transition without the automation. We keep track of the service process id at line 15, which we can use to stop the transition if needed:

GenServer.stop(state.transition)

LightTransition itself is fairly straightforward when we don’t have to keep track of the transition state:

defmodule Haex.LightTransition do
use GenServer
alias Haex.Light
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(opts) do
send(self(), :transition)
{:ok, Map.new(opts)}
end
@impl true
def handle_info(:transition, state) do
case state.transitions do
[] ->
{:stop, :normal, state}
[light_opts | rest] ->
Light.turn_on(state.entity_id, light_opts)
timer = Process.send_after(self(), :transition, light_opts.transition)
{:noreply, Map.merge(state, %{transitions: rest, timer: timer, last: light_opts})}
end
end
end

With this in place we can support pause and resume by using Process.read_timer() and Process.cancel_timer():

@impl true
def handle_call(:pause, _, state = %{timer: timer}) do
time_left = Process.read_timer(timer)
Process.cancel_timer(timer)
state =
state
|> Map.put(:time_left, time_left)
|> Map.delete(:timer)
{:reply, :ok, state}
end
def handle_call(:resume, _, state = %{time_left: time_left}) do
Light.turn_on(state.entity_id, state.last)
timer = Process.send_after(self(), :transition, time_left)
state =
state
|> Map.put(:timer, timer)
|> Map.delete(:time_left)
{:reply, :ok, state}
end

I think things turned out pretty well in the end.

State machines are great

So far we only have a sunrise alarm, but it’s easy to imagine more features that our humble lamp could support:

  • Snooze the wake-up light (using the above pause/resume functionality).
  • Circadian lighting.
  • A bedtime transition, similar to a reverse wake-up light except it shouldn’t force the light on.
  • A “max power mode” that sets the light to max brightness, triggered by toggling on/off quickly. Should only end when you turn off the light.
  • The all-important “sexy time” mode.
  • If a fire alarm goes off, flash the light in an aggressive way. Should of course override every other mode.

While you could implement them all as separate automations, the more you add the harder it gets to keep them from interfering with each other. You wouldn’t want your sexy time to be interrupted would you?

An alternative is to use a state machine to track the different states, making the state transitions more explicit. Our automation is already a simple state machine and it’s fairly easy to add more states and more functionality to it.

Automation testing

An automation is just an Elixir GenServer, so the same strategies to test a GenServer applies here too. I’ll start with the test I want to write, and we’ll work backwards to make it work:

test "trigger sunrise", %{server: server} do
# Start the sunrise by sending a time message to the automation.
send(server, {:time, ~T[06:00:00]})
# Assert that we'll eventually receive the sunrise transitions.
assert eventually(fn ->
[
%{brightness_pct: 100, kelvin: 2700},
%{brightness_pct: 80, color_name: "gold"},
%{brightness_pct: 70, color_name: "orange"},
%{brightness_pct: 10, color_name: "red"},
%{brightness_pct: 1, color_name: "red"}
] =
WebsocketClientCollector.get_messages(
get_service_data: true
)
end)
end

An isolated GenServer

The first thing we’ll need to do is to start the GenServer so we can start interacting with it. We don’t need a supervision tree so we can start it directly and send it to the test:

setup _opts do
{:ok, server} = BedroomLight.start_link([])
%{server: server}
end
test "trigger sunrise alarm", %{server: server} do
# ...
end

I like to test against isolated GenServers as it allows parallel testing and it reduces the risk of contamination from other parts of the application.

Alter the code to be able to test?

If we run this test we’ll notice that the automation will only output the first sunrise transition. What gives?

Remember this line?

Process.send_after(self(), :transition_sunrise, 10 * 60 * 1000)

It says that we’ll continue the sunrise transition after 10 minutes. Nobody wants to wait that long for a test to finish…

To get around this I added an option to the automation so that we can override the delay to 1 millisecond during the test:

setup opts do
opts = Map.put_new(opts, :transition_time, 1)
{:ok, server} = BoysRoofLight.start_link(opts)
%{server: server}
end
# And in the automation:
transition_time = state[:transition_time] || 10 * 60 * 1000
Process.send_after(self(), :transition_sunrise, transition_time)

I don’t like modifying code just to make tests work but in this case I think it’s a reasonable workaround.

The eventually helper

I want to touch on the eventually helper that I think is super useful when testing processes in Elixir. It comes in handy whenever I want to wait for a message to be delivered or wait for a process to reach a certain state.

Here it is:

def eventually(func, timeout \\ 1_000) do
# Use Task to be able to timeout the execution.
task = Task.async(fn -> _eventually(func) end)
Task.await(task, timeout)
end
defp _eventually(func) do
try do
if func.() do
# Return true so we can use it in an `assert` statement.
true
else
Process.sleep(10)
_eventually(func)
end
rescue
# Safe up so we don't have to bother with proper matches etc
# inside the predicate function.
_ ->
Process.sleep(10)
_eventually(func)
end
end

Careful use of checkpoints in our tests, where we wait for a state to be fulfilled, is much preferable over sprinkling Process.sleep() in our tests, hoping that the race conditions will go away.

Capturing sent websocket messages

The last thing we need is to capture outgoing websocket messages. In fact we also need to block the websocket connection because as it is now the full application will run when we run then tests, including connecting to our Home Assistant instance and start receiving state changed events.

We can do this by replacing the websocket client during tests. The application config is a good place for these settings:

config :haex,
ws_client: Haex.WebsocketClient
config :haex,
ws_client: WebsocketClientCollector,

Then when we send a message we delegate to the proper client:

def send(data) do
ws_client().send(data)
end
def ws_client() do
Application.fetch_env!(:haex, :ws_client)
end

All WebsocketClientCollector does is collect sent messages by process id and is able to return a list of them:

defmodule WebsocketClientCollector do
use GenServer
def send(msg) do
GenServer.call(__MODULE__, {:send, msg})
end
def get_messages(opts \\ []) do
GenServer.call(__MODULE__, {:get_messages, opts})
end
# Skipped the implementation ...
end

With this our test for the sunrise alarm should pass!

Beware of race conditions

Tests in an asynchronous and concurrent system—where messages don’t arrive immediately and where services interact with each other—can be very annoying to deal with as it’s easy to introduce race conditions, where a test sometimes fail.

Consider this test where we’ll test that the sunrise is aborted if the light is turned off in the middle:

@tag transition_time: 10
test "turn off light after sunrise alarm has begun halts it", %{server: server} do
send(server, {:time, ~T[06:00:00]})
assert eventually(fn ->
:sunrise = BedroomLight.get_state(server)
end)
# Should stop the sunrise
send(server, {:state, @entity, "off"})
assert eventually(fn ->
:day == BedroomLight.get_state(server)
end)
assert Enum.count(WebsocketClientCollector.get_messages(server)) == 1
end

Even though it appears we’re avoiding race conditions by waiting for the automation to change its internal state at line 4 and 11, this test may still fail on occasion.

The issue is that on the last line we’re testing that we only received a single sunrise transition. But we set a transition time of 10 milliseconds on line 0, and sometimes the messages arrive in such a way that the automation manages to transition twice.

To add some leeway in our test we might try to change the condition to < 4 and to increase the transition time…

What’s next?

We already have a working home automation engine that can be used as-is to control our home. But there are a couple of features that are missing and would enhance the system, for example:

  • Cron style support.

    We can add cron-like scheduling to our automations using libraries such as Quantum or Oban.

  • A simpler API for simpler automations.

    While GenServers are great in many ways they’re a bit verbose for simple automations. I took inspiration from AppDaemon’s listen_state for a simpler API:

    # This automation turns on a ledstrip behind my monitors when the plug power
    # is above 180, which happens when I turn on my three monitors.
    listen_state(
    "sensor.winterfell_plug_power",
    fn ->
    Light.turn_on("light.j_kontor_dator_ledstrip", color_temp: 220, brightness_pct: 40)
    end,
    gt: 180
    )

    listen_state is implemented by—you guessed it—a GenServer. listen_state registers a trigger callback together with some trigger conditions within the GenServer, then the server calls the callbacks whenever the conditions are met. This way we don’t need to mess with the internals of a GenServer and can use a declarative approach to create simpler automations.

  • Querying entity states.

    Sometimes we want to only execute an automation if an entity has a specific value, for example:

    if is_on("input_boolean.doorbell_sound_enabled") do
    # Trigger doorbell
    end

    I support this with the States GenServer that holds the state of every entity in Home Assistant. At startup it fetches all states and uses the state changed event we’ve seen before to keep it in sync.

  • Generate automation entities.

    Home Assistant dashboard to enable/disable automations.

    I want to be able to enable and disable the automations in the system. I’ve been manually creating input_boolean.<automation>_enabled entities, but our automation engine could create these manually. We could keep track of when the automation was last triggered and display the internal state of automations for debugging purposes.

    To set states (and create entities) we need to use the REST API.

There’s probably a bunch of things I haven’t yet realized that I need, but at the moment I’m really happy with writing my home automations in Elixir.

Trying and returning the Eight Sleep Pod 4

2024-10-05 08:00:00

I recently bought the Eight Sleep Pod 4—a smart mattress cover that tracks your heart rate, HRV, snoring, and cools or warms the mattress during the night. There’s a lot to like about the mattress but in the end I opted to return it.

This post describes my experience with the Pod 4.

Sleep is important enough to offset the steep price

The Eight Sleep mattress is really expensive but that’s not all—it’s a mattress with a subscription! I hated it when Oura introduced a subscription for their ring, and I hate the world that led us to a mattress with a subscription.

So why bother with the ridiculous pricing?

Because sleep is important.

What would 60 or even 30 minutes of extra sleep per day be worth? Or maybe the same amount of sleep but better? For me, as a parent of young kids that wakes up way too early, the answer is that it would be worth a lot.

That’s why I was able to look past the price and give Eight Sleep a chance.

My experience with the smart mattress

There are a bunch of things I like about the mattress and a bunch of things I didn’t like about it. The cons outweigh the pros for me but not by much; if my circumstances were a little different I might have kept it.

Pros

  • Sleep generally improved.

    I didn’t get the promised +30 minutes of extra sleep but anecdotally it was a positive change.

  • The mattress could get very hot and cold.

    I was worried that the mattress wouldn’t be able to get cold enough but it was able to go really cool.

  • Tap control on the side works very well.

    It was very easy to tap the side of the bed to increase or decrease the temperature (at least for me, the bed is flush to the other wall).

  • Separate sections of the bed is excellent.

    Although our kids slept with us the two sections worked well for us.

  • It’s a cool gadget—I like gadgets.

Cons

  • There’s no way to connect it to Home Assistant.

    I like home automation and I’ll freely admit that if I could’ve connected the bed to Home Assistant I would’ve kept it, everything else be damned.

    Using it as a presence sensor and being able to track the temperature of the bed and create my own automations would be glorious.

    But alas, Eight Sleep keeps all the data to themselves and want you to pay for the expensive subscription for the privilege of controlling your own mattress.

  • I sleep parts or even whole nights in my kids’ bed.

    To benefit from this kind of mattress you need to sleep on it, which I didn’t always do.

  • The app is a black box.

    I was severely disappointed in the app as it doesn’t provide any insight into what the Autopilot is doing, making you question if it does anything at all or if it’s just empty AI marketing.

    1. There’s no history of the temperature adjustments during the night.

      You can’t look back at the night and see your own or the Autopilot’s temperature adjustments. My own adjustments aren’t even saved so the temperature settings for the next night is a guessing game.

      Eight Sleep claims that Autopilot is making adjustments but for all I know it’s not doing anything.

    2. The “Autopilot has reduced your X by Y%” messages feels made up.

      I didn’t have the Pod 4 Ultra that can elevate the bed, so how can the Autopilot reduce my snoring during the night? Sometimes I didn’t sleep the whole night in the bed yet Autopilot claims it improved my deep sleep with 20%?

      I’ll give them the benefit of the doubt and say it’s probably bad statistics rather than regular old bullshit… But how can you tell?

  • Most importantly it did not achieve the partner approval.

Using the free 30 day return

I gave it a shot but after a few weeks I decided to use the generous free 30 day return to send back the pod and get a refund (you throw away the mattress).

It wasn’t the smoothest ride but the customer service did a decent enough job. I had lots of trouble with the pickup, although that was probably an issue with the shipping company rather than Eight Sleep:

  1. At first, they didn’t show up.
  2. The next time I didn’t get a label I could print, so they couldn’t take the package.
  3. Then they again didn’t show up.
  4. Finally, we tried another shipping company and Eight Sleep sent the label to me directly, then they picked it up.

I work from home so it wasn’t that big of a deal, although it was a bit stressful.

Still, the free return is great and it might be the biggest reason to try Eight Sleep. In the future, when the kids get older and if someone reverse engineers the next generation of the Pod to connect it to Home Assistant, I might give it a try again.

Why I still blog after 15 years

2024-09-25 08:00:00

Time flies when you’re having fun.

Before you know it, your little babies have started school, you celebrate the 30th anniversary of Jurassic Park, and that little blog you started have now been going for 15 years.

15 years is a long time; longer than I’ve been waiting for Winds of Winter, and that wait has felt like an eternity. How did I—who frequently abandon projects for the next shiny thing—manage to continue this blog for so long?

I’m as surprised as anyone but I’ve tried to make a retrospective of how this may have happened.

Why I started the blog

I started this blog because I wanted to create a bunch of fast game prototypes and I wanted somewhere I could write about my plans and, ultimately, the games.

You see, I was a budding programmer and I wanted to learn how to program by making a game. Not a simple game like Tetris—that would be way too sensible—no, I wanted to make a big RTS game, like StarCraft or Supreme Commander. And to do that you needed a game engine.

So I got stuck developing my engine with truly groundbreaking features such as:

  • A menu with keyboard and mouse support.
  • A console you could bring up with F2 where you could update variables (such as unit speed) without having to recompile.
  • You could select units with proper Ctrl, Shift, and right click behavior.

… But, embarrassingly, I didn’t have anything even resembling a game, and with the development speed I had I doubt I’d be finished to this day.

I’d gotten stuck in the Game Engine Trap, and I hated it.

Then I found The Experimental Gameplay Project (of World of Goo fame) that promoted the idea that you should be able to create a game prototype in just 7 days. That sounded like the perfect cure against the Game Engine Trap, so I created this blog to document my progress.

Why I’ve continued to blog

While the blog fulfilled it’s initial purpose as I developed around a dozen game prototypes that got me out of the Game Engine Trap (and that gave me a small “game engine” library at the end), I soon started write about other things.

There are a number of reasons I continued to blog:

  1. I enjoy writing.

    I realize now that the biggest reason I blog is that I enjoy the writing process. I can’t put my finger on why, I just generally like it.

    This isn’t always true though and I’ve had years where I’ve barely written anything at all (2022 for example). Sometimes I’ve had to force myself to write something.

    I guess the motivation ebbs and flows sometimes.

  2. Writing helps me think more clearly and helps me flesh out ideas.

    The act of writing something down helps me find errors in my thinking and helps me consider different viewpoints. Rewriting the text you’ve written has a similar benefit to refactoring your code; your thoughts will be more polished afterwards.

  3. Publishing something forces me to do better.

    If I’m going to put something out there I’m going to re-read and rework my text/code/ideas more than if I had kept it for myself. (Even if nobody will read your posts, the mere act of putting something out there has this effect I think.)

    For example, my custom keyboard layout wouldn’t have been nearly as well-developed if I hadn’t published it for everyone to see.

    Being more thoughtful about how I write is something I’ve become more cognizant of as the years have gone by. My first posts where little more than a stream of thoughts, while the larger posts I gravitate towards today have gone through multiple revisions and rewrites before I publish them.

  4. The blog is a place to document my personal projects.

    Over the years I’ve done other projects, such as built a 3D printer and wrote a book. It’s nice to have a place where I can write about them.

  5. Looking at a log of things I’ve done makes me feel better.

    I’ve been doing a small yearly review every year where I try to list the highlights of the past year. It’s been super helpful for me as it helps counteract the depressing feeling that nothing has happened and that I haven’t done anything.

    Doing a yearly review of some sort is a practice I highly recommend everyone to try, and of course you don’t have to publish it for everyone to see.

  6. I enjoy developing the blog as a project that exclusively solves my problems.

    Programming is my biggest hobby and I can’t see myself ever stopping. The blog is a great project as it’s something that exists only for me so I can rewrite, refactor, and add whatever silly features I want and I only have myself to answer to. It’s a nice feeling.

  7. Blogging helps me become a better writer, which in turns helps me become a better developer.

    I think communicating well is an important and underrated part of being an effective software developer. Writing well is a skill that can be developed by practice, and maintaining a blog is a pretty good way to practice I’d say.

My motivations aren’t dependent on external feedback

It’s important to point out that it’s not external feedback that has kept me going all these years. Yes, of course, it’s nice to get the occasional email with compliments, but that’s just a bonus.

I keep this blog for me to write, not necessarily for others to read.

Many of these kinds of retrospectives contain graphs of views over time or the most popular posts; but I’m not showing it to you because I can’t—I don’t keep any statistics whatsoever.

I don’t really care—and I don’t want to care—about how many readers I have or what posts are and aren’t popular. I worry that if I add statistics to the blog it’ll change from an activity I perform for the activity’s sake, to an exercise in hunting clicks where I write for others instead of for myself.

If I were chasing views I would certainly not have continued to blog for as long as I have, and I’d have missed out on the many benefits I’ve gotten from the blog.

Evolution of the tech stack

One of the reasons I’ve been blogging so long is that I’ve been able to play around with the tech stack of the blog. I’ve changed the tech stack a number of times; from choosing languages I wanted to learn, to a boring setup that “just works”, and back again.

I started out with PHP using the Kohana Framework and I still have fond memories of their excellent documentation. Although I had figured out how to create a website, it never graduated to a real blog.

Then I moved on to rewrite the site in Perl using Mojolicious. I’m not sure my efforts ever resulted in anything tangible but I remember if was fun to play around with.

I stumbled upon the idea of using a static site for my blog and therefore abandoned Perl for Jekyll, a popular static site generator at that time.

I believe it was a smart choice because it helped me start writing, instead of jerking around with cool tech.

Eventually, I grew tired of the boring backend that just got the job done and in my quest to learn Haskell I replaced the generator with Hakyll, another static site generator with a pretty neat DSL.

The earliest Git commit on record. I’m fairly sure I used Git before this point
(I abandoned SVN for my games in 2009).

Sadly, I never truly graduated from the “throw shit at the wall until it sticks” stage of my Haskell journey, which is why I barely added any features to the blog for many years.

Having outgrown existing solutions I decided to join the Rewrite in Rust club (or is it a cult?)

Religious weirdness aside, having complete control of the site generator made it fun again to tinker and add small features.

Honestly though, my favorite piece of technology on the blog is CSS. I just really like to spend time to fiddle with the design and to make small tweaks here and there. I do use Sass but 95% is just plain CSS.

Modern CSS is honestly great.

Almost by accident I started using Djot instead of Markdown to write my posts. I couldn’t find a Tree-sitter grammar for Djot so I created one.

I’m in the process of connecting the site generator to Neovim to provide autocomplete, diagnostics, jumping between posts, and other cool features.

There’s lots of potential for spending tons of time in this swamp but these IDE-like features really elevate the writing experience.

At the moment the blogging software is a whole project in and of itself (by design; it’s a fun project to tinker with).

Posts have changed focus and increased in scope

:post-stats-graph:

It probably comes as no surprise that my posts have changed a lot since I started the blog. I made the above visualization that counts the words of each post and plots them on a time axis, together with loose grouping of the type of post.

I have two main takeaways:

  1. The posts have grown larger and more ambitious.

    In the beginning I treated the blog almost like a Twitter/X feed with short updates on my game making progress. Now I spend weeks or even months slowly working away on a post until I feel it’s interesting and polished enough to publish.

  2. As my interests have changed, so has the focus of my posts.

    I only write about my hobbies or things that I’m interested in at that moment so it’s only natural that the theme of the posts have changed. Gaming related posts have given way for more programming and the occasional meat-space related project.

What does the future bring?

I find almost find it obvious that the blog has changed so much during the 15 years of it’s existence; of course my posts would grow more ambitious as my writing matured and I’d obviously start gravitating away from games towards other projects.

Naturally, it’s just a lie I tell myself with the benefit of hindsight.

Predicting the future is impossible and I have no idea what the blog will look like 15 years from now. While it feels like I’ll keep blogging the same way, it would be foolish to claim that as a fact.

Sometimes it’s best to stop worrying and just enjoy the ride.

A simple timeline using CSS flexbox

2024-08-25 08:00:00

As I added a /now page to the site I also decided to refresh my /about page and I figured it would be neat to have timeline element where I could list some of the larger events in my life.

To my surprise it wasn’t too difficult to create one that looks pretty clean—the flexbox feature in CSS is really good. In this post I’ll walk you through how I made this kind of timeline:

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Markup

I like to start with the markup before moving on to styling. I have two wrappers (timeline and events) around the different events (event) that contains the event marker (svg) and content with a time and text:

<div class="timeline">
<div class="events">
<!-- The first `1989` event -->
<div class="event life">
<!-- The circle is an svg -->
<svg
class="marker"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
>
<circle cx="6" cy="6" r="6"></circle>
</svg>
<!-- The event info -->
<div class="content">
<time>1989</time>
<div class="text">
<p>I was born in the north of Sweden</p>
</div>
</div>
</div>
<!-- etc ... -->
</div>
</div>

A simple line

Let’s start with the actual line in the timeline. I chose to use the ::before pseudo-element on the events div to simulate a line by setting the width and height:

.events::before {
// We need some content for the element to show up.
content: "";
// Use absolute positioning to place the timeline at the very top.
position: absolute;
top: 0;
// With a height and with the timeline will be a tall and thin box.
height: 100%;
width: 1px;

We also need to set the wrapper .events to use relative positioning, otherwise the timeline will start from the top of the page, not the container:

.events {
position: relative;
}

I’ll also throw in a little bit of styling so it’s easier to see:

// For the tutorial I use slightly different colors,
// but you get the idea.
.events::before { background: white; }
// Events use different classes to differentiate them.
.event.life .marker { fill: yellow; }
.event.programming .marker { fill: magenta; }
.event.family .marker { fill: red; }
// Make the time stand out
.content time {
font-family: concourse_4, Helvetica, sans-serif;
font-weight: bold;
}
// Just some extra spacing to make the timeline not merge
// with the surrounding text.
.events { margin: 0.5em; }

And we have our line for our timeline:

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Alignment

The circle and event aren’t aligned, let’s try to fix that.

By using flexbox the event will display its content horizontally (with the circle to the left and the content to the right):

.event {
display: flex;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Close, but the circle seems off. Remember that the circle is an svg 12 pixels wide and high and positioning will use 0,0 by default.

With relative positioning we can move the center of the circle to align it better:

.event .marker {
position: relative;
left: -6px;
top: 6px;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

But if you look closely this still doesn’t look correct. Turns out that centering things is the hardest problem in computer science, so don’t be discouraged.

To save you some grief, I found that align-items: baseline does a better job than nudging top positioning:

.event .marker {
position: relative;
left: -6px;
top: 0px;
}
.event {
align-items: baseline;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

(The alignment looks good enough to me, at least with the default font I use.)

Vertical spacing

It feels a bit cramped so lets space things out. One way is to simply add a margin-bottom: 1em; but that would add a useless space below the last event (that we’d have to remove another way).

I think a cleaner way is to use flexbox and row-gap to only specify spacing between elements:

.timeline-5 {
.events {
display: flex;
// Lay out events column-wise instead of row-wise.
flex-direction: column;
// Set some spacing between elements.
row-gap: 1em;
}
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Making it responsive

What we’ve made is good for smaller screens but for larger screens I’d like to place the line in the middle and move some events to the left and some to the right.

I’ll use media queries to create a cutoff:

@media (min-width: 700px) {
// Styling for wider screens goes here.
}

Even though I won’t include the media query in the following code snippets the media query should wrap them all.

Events to the left

The first thing I’d like to do is move the line to the middle:

.events::before {
// This centers the line horizontally.
// Remember that we used absolute positioning before.
left: 50%;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

(Use a wider screen to see the effects of our changes.)

Now, let’s move the marker to the timeline. First lets move the marker to be after the content in the layout ordering:

.event .marker {
order: 1;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Secondly, we’ll make the content take up all the space to the left, pushing the marker on top of the line in the middle:

.event .content {
width: 50%;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Lets move right-align the content and add some padding so the text won’t overlap with the marker:

.event .content {
text-align: right;
padding-inline: 1em;
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

Events to the right

To move events to the right side of the timeline all we have to do is tell flexbox to lay out elements from right to left instead of left to right:

// Use `nth-child(even)` to target every other event.
.event:nth-child(even) {
// Layout elements from right to left.
flex-direction: row-reverse;
}

To make it look good lets add left aligned text and move the marker offset to be aligned over the line again:

.event:nth-child(even) {
.content { text-align: left; }
// The marker used to be offset -6px, but now we
// move from the right.
.marker { left: 6px; }
}

I was born in the north of Sweden

I got introduced to Visual Basic

Got together with Veronica

We’re done

That’s all there is to the timeline I use. You can of course modify and expand on it in many ways but I quite like this simple styling.

With flexbox it was in the end fairly simple to get a basic timeline created and it’s one of my absolute favorite CSS features that manages to simplify many things that used to be very awkward.


Here’s the all the styling for the timeline we created in this post:

// The line in the middle.
.events::before {
content: "";
position: absolute;
top: 0;
height: 100%;
width: 1px;
background: var(--color-hr);
}
.events {
// Needed for positioning the line.
position: relative;
// Add some space.
display: flex;
margin-block: 0.5em;
flex-direction: column;
row-gap: 1em;
}
.event {
// Layout content and marker using flexbox.
display: flex;
// Align marker vertically.
align-items: baseline;
}
.event .marker {
// Adjust marker to center on the line.
position: relative;
left: -6px;
}
// Some coloring to make our life easier.
.event.life .marker {
fill: var(--melange_b_yellow);
}
.event.programming .marker {
fill: var(--melange_b_magenta);
}
.event.family .marker {
fill: var(--melange_b_red);
}
.content time {
font-family: concourse_4, Helvetica, sans-serif;
font-weight: bold;
}
@media (min-width: 700px) {
// Place the line in the middle.
.events::before {
left: 50%;
}
// Layout the marker after the content.
.event .marker {
order: 1;
}
.event .content {
// Make the content take 50% space so the marker
// will be placed at 50% (on top of the line).
width: 50%;
// Event is to the left, align text towards the line.
text-align: right;
// Avoid overlap with the marker.
padding-inline: 1em;
}
// For these types, move the event to the right.
.event:is(.programming, .work, .projects) {
// Layout the content and marker from right to left.
flex-direction: row-reverse;
// Now align text to the left.
.content {
text-align: left;
}
// We used to offset the marker from the left with -6px,
// now we need to do it from the other side.
.marker {
left: 6px;
}
}
}