ESPHome packages and substitutions tutorial: managing complex configurations effectively
Over the course of this year I've been getting closer to my goal of regulating temperature in each room of my house individually. I made small ESPHome devices to measure ambient and floor temperatures and placed them in every room. I also installed my smart thermostat at my relatives' house and flashed a couple of Shelly relays with ESPHome. Needless to say, my ESPHome configurations quickly got out of hand, so it was time to introduce some unification and refactor tons of copy-pasted yaml. In this blog post I will describe the system that I'm currently using. You can use my base configurations with your own devices by referencing them from my repo:
How packages and substitutions work
ESPHome has two main tools for configuration composition: packages and substitutions. When I first read the documentation on these features, I struggled to understand how are they interconnected and what approach would be optimal for me. Here I will try to explain these features together and build a composition system on top.
Substitutions
Substitutions are quite simple: they are just shell-style variables that can be overriden in config file hierarchy. Let's consider the simplest compilable config file for ESPHome:
If you run esphome config inner.yaml
, you will see that frinedly_name
gets substituted with default value specified in substitutions
section. This is already useful for cases when you need to use the same value in different places in your config file.
Includes
Substitutions become even more powerful when used with !include
statement. In ESPHome you can include arbitrary config snippets almost anywhere, while specifying values for substitutions that are used in the file you are including. There is a good example in ESPHome documentation, which I will not repeat here for brevity.
The important thing here is that you can omit substitutions
section with all the default values if you are not going to compile this file directly. Just be sure to provide substitution values for each file you are including.
Packages
The most interesting feature that allows you to build flexible composition architecture is Packges. They are different from !include
statement in their ability to intelligently merge configurations. Consider the following configuration:
If you run esphome config sensors.yaml
, you will notice that while configuration is considered valid, contents from sensor2.yaml
is silently dropped, because YAML doesn't allow the duplicate sensor:
section. But what happens if we import sensor1.yaml
and sensor2.yaml
as packages?
Now when you run esphome config sensors.yaml
, you will see that both sensors were included! This happens because package component intelligently merges config sections based on node names and component ids.
Substitutions in packages
Now we can go even further and eliminate repetition by extracting a generic sensor package and substituting the number in our main file:
After checking the result with esphome config sensors.yaml
, you will see that we achieved the same result as before, but reduced copy-pasted config even further. As you can see, package variables and substitutions are in fact the same thing, and this is what confused me a lot when I read the official docs. For a while I thought they are different, but as it turns out, you don't need to change anything about your inner config files, whether you include them with a simple !include
or as a package.
Package hierarchy and substitutions
The last piece of the puzzle is the relation between packages, substitutions
and defaults
sections. Official docs say that a package can have a defaults
section where default values for that package's substitutions can be specified. I'm not clear why did ESPHome creators decided to use two separate ways of doing basically the same thing — maybe defaults
section came first, and when substitutions
section was introduced, they decided to keep backwards compatibility.
Let's consider two levels of configuration:
After running esphome config top-level.yaml
you will see that frinedly_name
got replaced with value from defaults:
section in inner.yaml
, ignoring substitutions
section from both files. But if we comment out the defaults:
section, things change.
Now friendly_name
got its value from top-level.yaml
substitutions:
section. So we can deduce the following rule:
package → vars:
section, substitution at any depth will take its value from the outermost substitutions:
section where its value is specified.This still means that we can specify substitution value directly in package → vars:
section and it will override values from substitutions
at any depth. To prove this, let's test three layers of packages:
No matter how deep in include hierarchy the substitution foo
is used, it still receives the Overriden!
value explicitly defined in top-level.yaml
.
substitutions:
blocks, abandoning the use of defaults:
completely. This is, of course, my opinion, which can be wrong 😄How I manage my configuration
I hope that my examples help you better understand how packages and substitutions work. There are still more cool package features that I didn't cover here, like Extend and Remove directives, but my goal was to explain the main rules of package composition and not copy the official docs 😄 Now I will try to explain how I manage my own ESPHome configurations.
Composition over inheritance
My first stab at refactoring my configurations was an idea to use some sort of inheritance, where there is one main device template that can be configured in different ways to do different things. I quickly abandoned this idea because it proved difficult to create such a universal configuration.
Instead I opted for small, composable yaml snippets that can be included as packages so their contents is intelligently merged by ESPHome. Each package is focused on a single task. For example, specify device board and framework, configure Wi-Fi, logging, etc.
For each ESPHome device I then create a separate yaml file, most of which consists of package include statements and some configuration using substitutions
. Let's look at some of the base files that I use.
Wi-Fi, OTA, and Home Assistant API
All of my devices that connect to Wi-Fi, should also connect to Home Assistant and have an OTA update capability. I decided to lump all these sections in a single file, but you may split them in different files depending on your requirements.
There are two substitutions, wifi_ip
and wifi_gateway
that are configured in top-level files. I configure wifi_gateway
as a substitution because I have two homes that reside in different subnets with one main router in each subnet.
Base device: Wi-Fi, logging and restart
Then comes the base device package that combines previously defined Wi-Fi package with logging code that calls Home Assistant and a restart button:
I was looking for a solution that would allow me to continuously ship device logs to Loki. I decided against making an external HTTP call on each logging operation (my internet might be down), and opted to call a Home Assistant service that writes logs from all my devices to its system log, which is later shipped to Loki with Promtail add-on.
Boards
I use several boards in my projects, so I created a package for each combination of a board and target framework. Here are some of them:
Debugging
There are also a couple of packages that can help debug devices. I don't usually include them in device config, but sometimes they are useful. Here is a package that configures debug sensors:
And debug logging package. There are a lot of entries that are commented out — I just tune them on the spot when there is a need to add debug logs to a device.
Putting it all together
Now let's stitch all these files together. In each room in my house I have devices that measure room and heated floor temperatures. In order to manage them all, I first create a base configuration.
Base device config
As you can see, this package uses pre-configured .base_device.yaml
and .board.s3_micro.yaml
, as well as defines two substitutions of its own: temp_floor_address
and temp_room_address
. These are the addresses of Dallas temperature sensors that I will configure for each end device.
End device config
And finally let's configure an example end device.
As you can see, there is not much that goes into end device config. I set all the substitutions that are scattered among files in this one place — it's easier to track them this way. I also set device-specific sensor addresses and include one base device package. And we are done! 🎉
Conclusion
Here I describe a heavily opinionated approach to managing ESPHome configuration files. It's still evolving and you shouldn't adopt it blindly. Look at your requirements and make a system that works for you. I hope this tutorial is helpful for people who, like me, struggled to understand how both packages and substitutions work in tandem.