Eventually I got tired of constantly re-signing and re-uploading Kodi to my Apple TV (and being stuck with an old version). So I got a Vero V instead, and immediately it became clear that I should have done so from the beginning. Buying Apple TV was a mistake: like I said before, if not for running Kodi, Apple TV is a pretty useless device (especially now that I have a TV that supports AirPlay).

Vero V with connected SONOFF Zigbee 3.0 USB Dongle Plus-E

Around the same time I entered the domain of home automation. At first it was just Homely (a norwegian home security system), which comes with their own hub and a (not very large) set of supported devices/sensors. But soon enough I discovered that it’s all just a Zigbee network wrapped into a user-friendly package, so I could do more with my own hub and a much wider (and cheaper) collection of devices and sensors. Shortly after that realization I stumbled upon Home Assistant, as all roads lead to it, and, you guessed it right, I decided to host and run it on that same Vero V device.

Is something wrong with Homely

I said that I started with Homely before getting into Home Assistant, and that might have given you an impression that I switched from one to another. But no, I still use Homely, it just serves a different purpose, so it is not that I have replaced it with Home Assistant.

Homely isn’t a yet another automation hub with some devices. Its main purpose is to connect your home to their 24/7 monitoring system, and if something happens - smoke sensor detects fire, or alarm is triggered by door/window/motion sensors - monitoring will get notified, and they will call firefighters/police/whoever for you. As a bonus to that, you also get some basic home automation.

As you can imagine, such a system cannot depend on a random 1 USD sensor from uncle Lao, as it all needs to be certified/approved, which I guess is why their list of supported devices is not very long. Mostly it’s Frient/Develco/Onesti devices (they all seem to be the same thing, just re-branded), but there are also IKEA bulbs and other vendors there too, although some of them also seem to be just re-branded variants of the same things, so the “actual” list might be even shorter:

Homely, supported vendors

I cannot say how good (or bad) their 24/7 monitoring is, as I never had any accidents that would trigger an alarm (except for when I forgot about the alarm and opened the balcony door during the night time, while the alarm was armed, and they called me right away), and hopefully I will never know how good (or bad) their monitoring is. I can only say that out of all the security systems available in Norway, Homely’s monthly subscription fee is the lowest (in 2024 it was plain 199 NOK per month, without cheeky offers like a “free” smart lock installation), and they are recognized by insurance companies, so you can get a discount on your house insurance too. They do overprice the devices/sensors though, so you can most likely find several of those cheaper in other places.

The mobile application is okay, although looks like it’s not their own but a customized (re-styled) variant of Keyfree. The nimly application seems to be using the same. Or maybe there is some other common “parent” for all of them, but either way, they all definitely look the same.

Surprisingly, there is no web-version, so you are limited to controlling it from the mobile application only. And by the way, there is no proper iPad version, it just uses the same iPhone application, which isn’t optimized for non-iPhone screens.

Coming back to home automation, it’s actually not the small number of supported devices that made me look elsewhere, but the very limited set of automation rules/triggers. For example, you cannot create a rule to turn on the lights when movement sensor detects you entering the room, which seems to be a very common routine to have. That’s how I got to Home Assistant, where automation capabilities are practically limitless, and I left Homely to handle just the security/safety stuff.

Why not a dedicated device

Yes, I could’ve gotten a dedicated device to run Home Assistant - Raspberry Pi or maybe their own Green/Yellow boxes - and they are rather inexpensive, but that’s yet another box cluttering my shelves.

I already have Vero V, and it is quite a powerful device - 2 GHz ARM CPU (4 cores), 4 GB RAM, 32GB eMMC. Kodi is barely taking 10% of resources even while playing 4K movies, so there is a lot of room for doing something else, such as running Home Assistant, and it would be wasteful not to do so.

Environment and versions

Just in case, here’s my environment and versions of the software/firmware that I have:

  • Vero V:
    • Debian: 11.10
    • OSMC: 2024.05-1
  • Home Assistant: 2024.7.3
  • Zigbee adapter/coordinator: SONOFF Zigbee 3.0 USB Dongle Plus-E
    • type: EZSP v8
    • revision: 6.10.3.0 build 297
  • Zigbee2MQTT: 1.39.0, commit 0326926

Installation

On the first connect, before anything else, change the default osmc password to a proper one with passwd, deploy an SSH key and disable passwords authentication.

Docker

Out of all the possible ways of installing Home Assistant I chose Docker. Mainly because I have never used Docker for anything (aside from doing some tutorials), so this seemed like a good opportunity to finally try it out for something actually useful. Secondly, from the little I knew about Docker, one of its distinguishing features is the ability to run stuff in an isolated environment, which was just the thing I needed, as I didn’t want to disrupt the default Vero/OSMC environment too much, especially given that some of the Home Assistant components are run with godawful things like Node.js.

One thing you should be aware of is that going with Docker way of installing Home Assistant you will lose certain features such as addons out of the box. That would be a major disadvantage or even a show-stopper, but actually it’s no worries, because those addons turned out to be… also Docker containers, which in other types of Home Assistant installation are just managed by the Home Assistant itself, so you absolutely can do the same on your own (and probably better too).

First you will need to install Docker, as it isn’t preinstalled in OSMC by default. Following the trend of running random Bash scripts from the internet, Docker installation is supposed to be carried out that way too:

$ mkdir ~/downloads && cd $_
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ less ./get-docker.sh

I scrolled the script contents and didn’t see anything concerning enough, so I gave up and just ran it, but I certainly condemn this retarded practice. The “installation”:

$ sudo sh ./get-docker.sh --dry-run
$ sudo sh ./get-docker.sh

$ sudo systemctl enable docker.service
$ sudo systemctl enable containerd.service

$ sudo docker run hello-world

After the installation it is probably a good idea to limit Docker logs size right away. The following should limit logs to the maximum of 5 files, each being 10 MB of size:

$ sudo nano /etc/docker/daemon.json
{
    "log-driver": "json-file",
    "log-opts": {
        "max-size": "10m",
        "max-file": "5"
  }
}
$ dockerd --validate --config-file=/etc/docker/daemon.json
configuration OK

Also, you might want to run Docker from a non-root user, but for simplicity I decided to go with root, at least in the beginning, and I will probably make it non-root later.

Home Assistant

The installation or rather deployment procedure for anything Docker-based is to create a container from a specified image. If you don’t have the image available locally already, it will get fetched from Docker Hub. Which begs the question whether one can get images and host them somewhere locally and independently from Docker Hub, and fortunately that seems to be supported too.

The process of creating/running a Home Assistant container is the following:

$ sudo docker run -d \
    --name homeassistant \
    --restart=unless-stopped \
    -e TZ=Europe/Amsterdam \
    -v /root/homeassistant:/config \
    ghcr.io/home-assistant/home-assistant:stable

The -v argument here tells the container that host paths (what’s on the left from :) will be mapped to container paths (what’s on the right from :). Here Home Assistant settings (/config/ path inside container) will be stored in /root/homeassistant/ path in the host filesystem. So when container will be deleted/re-created (during an update, for example), the data (volume) will be preserved between container reincarnations. In addition to that, we can edit the configs in the host filesystem without logging-in to the container.

In their manual they also set --privileged and --network=host, but there is no need for that, as well as there is probably no need for -v /run/dbus:/run/dbus:ro (if you don’t intend to use Bluetooth). The Home Assistant team probably added those options to reduce the number of users complaining that “nothing works”, but in general these parameters certainly aren’t great for something that is supposed to be running isolated from the main/host environment.

IP address of the running container

In order to interact with Home Assistant, you will need to know the local IP address of its container. One of the ways to get it is to inspect the container:

$ sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' homeassistant
172.17.0.2

$ ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.294 ms
64 bytes from 172.17.0.2: seq=1 ttl=64 time=0.263 ms
^C

You can also try to query it with cURL by sending a HEAD request to the default Home Assistant port 8123, and it should reply that HEAD method is not allowed (if you haven’t performed the initial configuration/onboarding already):

$ curl -I 172.17.0.2:8123
HTTP/1.1 405 Method Not Allowed
Content-Type: text/plain; charset=utf-8
Allow: GET
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Server:
X-Frame-Options: SAMEORIGIN
Content-Length: 23
Date: Mon, 22 Jul 2024 19:42:29 GMT

The GET request should return something like this:

$ curl -v 172.17.0.2:8123
*   Trying 172.17.0.2:8123...
* Connected to 172.17.0.2 (172.17.0.2) port 8123 (#0)
> GET / HTTP/1.1
> Host: 172.17.0.2:8123
> User-Agent: curl/7.74.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 302 Found
< location: /onboarding.html
< Referrer-Policy: no-referrer
< X-Content-Type-Options: nosniff
< Server:
< X-Frame-Options: SAMEORIGIN
< Content-Length: 0
< Content-Type: application/octet-stream
< Date: Mon, 22 Jul 2024 19:42:45 GMT
<
* Connection #0 to host 172.17.0.2 left intact

So yes, this IP address is definitely from the running Home Assistant container.

However, on the next restart/reboot the container might get a different IP address, but we need it to persist. Better yet, it would be great if there was some “internal” DNS - to be able to address containers by their names instead of IP addresses, especially when it comes to other containers “talking” to each other within their network. Sadly, by default the name resolution is disabled, and you can test it right now by spawning a new container:

$ sudo docker run -it --rm alpine /bin/ash

/ # ip route get 1 | awk '{print $NF;exit}'
172.17.0.3

/ # apk --no-cache add curl

/ # curl -I http://172.17.0.2:8123 -v
*   Trying 172.17.0.2:8123...
* Connected to 172.17.0.2 (172.17.0.2) port 8123
> HEAD / HTTP/1.1
> Host: 172.17.0.2:8123
> User-Agent: curl/8.8.0

/ # curl -I http://homeassistant:8123 -v
* Could not resolve host: homeassistant
* Closing connection
curl: (6) Could not resolve host: homeassistant

/ # exit

As you can see, this new temporary (because of --rm) container got an IP address in the same network (172.17.0.3) and it could reach the homeassistant container by IP address, but could not reach it by hostname.

Fortunately, the name resolution can be enabled after all, it just isn’t enabled in the default network. Check what networks you already have:

$ sudo docker network list
NETWORK ID     NAME      DRIVER    SCOPE
f7dc4c461ccb   bridge    bridge    local
e6e7d7a437b4   host      host      local
ecc68fac9a4b   none      null      local

The one with bridge driver is the default network (172.17.x.x) where new containers are created, and that is where homeassistant container is running now too.

But if you create a new network with the same bridge driver:

$ sudo docker network create --driver bridge hub
5dfbe1989378e089d5548ae21e4b48b055108b570be7ac717a0d9ad034b79817

$ sudo docker network list
NETWORK ID     NAME      DRIVER    SCOPE
f7dc4c461ccb   bridge    bridge    local
e6e7d7a437b4   host      host      local
5dfbe1989378   hub       bridge    local
ecc68fac9a4b   none      null      local

then it will be a “user-created network”, and these have “automatic service discovery” - that very same DNS that we need. Now you can re-create your container in that new network (--network hub) or you can just move it from the default network (172.17.x.x) to this new one (172.18.x.x):

$ sudo docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "f7dc4c461ccb9d518abcdec62207554d809bc973d874c21f55b1d92427223d0d",
        "Created": "2024-07-22T21:16:52.221175914+02:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "7e9bc60ef0f894eec68eaf784b06c0d4605ee5dc52d5733c9f0394de67a3943c": {
                "Name": "homeassistant",
                "EndpointID": "922f23693b311435906df168af5c669c606b9c41e8bca061d01f44056573922f",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

$ sudo docker network inspect hub
[
    {
        "Name": "hub",
        "Id": "5dfbe1989378e089d5548ae21e4b48b055108b570be7ac717a0d9ad034b79817",
        "Created": "2024-07-23T09:44:48.167021383+02:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {},
        "Options": {},
        "Labels": {}
    }
]

$ sudo docker network disconnect bridge homeassistant
$ sudo docker network connect hub homeassistant

$ sudo docker network inspect bridge
[
    {
        "Name": "bridge",
        "...": "...",
        "Containers": {},
        "...": {}
    }
]

$ sudo docker network inspect hub
[
    {
        "Name": "hub",
        "...": "...",
        "Containers": {
            "7e9bc60ef0f894eec68eaf784b06c0d4605ee5dc52d5733c9f0394de67a3943c": {
                "Name": "homeassistant",
                "EndpointID": "db73973bfdc5d9b8e1807252da52cbfd6fda9c17899918377f28d9a21d54dfb2",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "...": {}
    }
]

If you now create a new temporary container in the hub network, then you should be able to reach the homeassistant container from it by both IP and hostname:

$ sudo docker run -it --rm --network hub alpine /bin/ash

/ # ip route get 1 | awk '{print $NF;exit}'
172.18.0.3

/ # apk --no-cache add curl

/ # curl -I http://172.18.0.2:8123 -v
*   Trying 172.18.0.2:8123...
* Connected to 172.18.0.2 (172.18.0.2) port 8123
> HEAD / HTTP/1.1
> Host: 172.18.0.2:8123
> User-Agent: curl/8.8.0

/ # curl -I http://homeassistant:8123 -v
* Host homeassistant:8123 was resolved.
* IPv6: (none)
* IPv4: 172.18.0.2
*   Trying 172.18.0.2:8123...
* Connected to homeassistant (172.18.0.2) port 8123
> HEAD / HTTP/1.1
> Host: homeassistant:8123
> User-Agent: curl/8.8.0

/ # exit

While that works, it doesn’t mean that you will also be able to reach that container by hostname from your host environment too, because you won’t:

$ curl -I http://homeassistant:8123 -v
* Could not resolve host: homeassistant
* Closing connection 0
curl: (6) Could not resolve host: homeassistant

as containers name resolution can work only within their networks.

NGINX

Alright, Home Assistant is running and other containers can reach it. But you as a user are not able to reach it yet, because its port 8123 isn’t exposed from the host.

There are several ways of exposing a container port from the host, and I prefer the one with NGINX being a reverse proxy. So let’s install NGINX:

$ sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring
$ curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
$ gpg --dry-run --quiet --no-keyring --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg

$ echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/debian `lsb_release -cs` nginx" | sudo tee /etc/apt/sources.list.d/nginx.list
$ echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" | sudo tee /etc/apt/preferences.d/99nginx

$ sudo apt update
$ sudo apt install nginx
$ sudo systemctl status nginx.service

$ sudo chown -R www-data:www-data /var/www

The site config for Home Assistant would be the following:

$ sudo nano /etc/nginx/sites-available/homeassistant
# this is supposed to be used for WebSocket connections,
# although it might require a separate `location` block
map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

server {
    # doesn't have to be exactly `8123`, you can set it to the "usual" `80`
    listen 8123;

    location / {
        # `172.18.0.2` is the IP address of the Home Assistant container
        proxy_pass http://172.18.0.2:8123;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
$ sudo ln -s /etc/nginx/sites-{available,enabled}/homeassistant
$ sudo systemctl restart nginx.service

$ ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p'
192.168.1.35

Now you should be able to open http://192.168.1.35:8123/ (or whichever IP address you have for your device/server) from a web-browser on your computer/tablet/phone (as long as they are in the same network). Obviously, this IP address needs to be statically assigned to the device/server (Vero V in my case) in your home network DHCP server (a router, usually).

What if container IP address will change

But what to do if container IP will change on the next restart/reboot? Since the container’s hostname isn’t resolving in the host environment (as we know, it resolves only within a user-defined Docker network), we’ll have to rely in the “bare” IP address. But we at least can make it “static”, so container doesn’t get a different address on the next restart/reboot:

$ sudo docker network disconnect hub homeassistant
$ sudo docker network connect --ip 172.18.0.100 hub homeassistant

$ sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' homeassistant
172.18.0.100

Here it is assumed that you have already moved the container from the default bridge network to the new user-defined hub network.

Don’t forget to edit NGINX config and change the IP address from 172.18.0.2 to 172.18.0.100.

HTTPS

I don’t plan to expose my Home Assistant instance to the internet, at least not without a VPN, so I intend to use it from the local home network only, but even there it is still worth to encrypt the traffic, so credentials aren’t sent in plain text.

A self-signed certificate should be enough for this purpose:

$ sudo mkdir /opt/certificates
$ mkdir /tmp/crts && cd $_

$ openssl req -x509 -newkey rsa:2048 -nodes -sha256 \
    -subj '/CN=Home Assistant' -extensions EXT -config <( \
    printf "[dn]\nCN=Home Assistant\n[req]\ndistinguished_name=dn\n[EXT]\nsubjectAltName=IP:192.168.1.35,DNS:vero.local\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") \
    -days 1111 \
    -out fullchain.pem -keyout key.pem

$ ls -l
-rw-r--r-- 1 osmc osmc 1115 Jul 30 11:11 fullchain.pem
-rw------- 1 osmc osmc 1704 Jul 30 11:11 key.pem

$ sudo mv ./*.pem /opt/certificates/
$ sudo chown -R www-data:www-data /opt/certificates

Again, the 192.168.1.35 is the (static) IP address of your hub/device, which in my case is Vero V. The subjectAltName=IP:192.168.1.35,DNS:homeassistant.local might be just the IP, if you don’t have a DNS record for it in your home network. I actually don’t have it, and as a matter of fact, you can probably just drop the entire line altogether, so the command becomes:

$ openssl req -x509 -newkey rsa:2048 -nodes -sha256 \
    -subj '/CN=Home Assistant' -extensions EXT -config <( \
    printf "[dn]\nCN=Home Assistant\n[req]\ndistinguished_name=dn\n[EXT]\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") \
    -days 1111 \
    -out fullchain.pem -keyout key.pem

Then to enable HTTPS in NGINX site config:

$ sudo nano /etc/nginx/sites-available/homeassistant
server {
    listen 8123 ssl;

    ssl_certificate /opt/certificates/fullchain.pem;
    ssl_certificate_key /opt/certificates/key.pem;

    # ...
}
$ sudo systemctl restart nginx.service

Check the certificate expiration date from some other machine:

$ echo | openssl s_client -connect 192.168.1.35:8123 2>&1 | openssl x509 -noout -dates
notBefore=Jul 30 09:40:32 2024 GMT
notAfter=Aug 15 09:40:32 2027 GMT

Open https://YOUR-VERO-IP:8123/ (note that it’s https:// now, as http:// won’t work anymore). Since it’s a self-signed certificate, web-browsers will give you warnings, and mobile applications will do so too:

Home Assistant application, untrusted certificate

Here it also says that the validity period is too long. But who cares, it is you who issued the certificate, and you certainly trust yourself, don’t you. For the purpose of encrypting your local network connections that should be totally fine. One can even say that this whole ordeal with HTTPS in a local home network might be redundant.

If/when at some point later you will re-issue the certificate, for example to renew it, then Home Assistant UI might fail load in browser:

Home Assistant not loading in browser because certificate is invalid

printing this useless error message to the console:

Firefox can't establish a connection to the server at wss://192.168.1.35:8123/api/websocket. core.7weG3TqZ_G4.js:1:65105
Uncaught (in promise) 1 

Mobile applications will fail to connect to, but you’ll get a more informative error message there:

Home Assistant application not loading because certificate is invalid

which simply means that you need to refresh the page with clearing cache, so clients would get the new certificate.

Zigbee

To communicate with Zigbee devices you need to have a Zigbee radio adapter/module with a coordinator firmware. Many adapters are supposed to be connected via USB, so they are plugged into the hub/server where Home Assistant is running, and preferably not into USB 3.0 port (fortunately, Vero V has both USB 3.0 and USB 2.0 ports) because of the interference. Some adapters (here’s one example) connect via RJ45 port, which might be a better option.

There are several such adaptors/modules available, and I chose SONOFF Zigbee 3.0 USB Dongle Plus-E. I didn’t know how to compare them, I just took the one that is mentioned the most.

You will also need an appropriate software, which will use this adaptor to communicate with your Zigbee devices and organize the network. The most popular options seem to be these two:

  • Zigbee Home Automation (ZHA) - probably the easiest one to configure and use. It is also an officially supported one;
  • Zigbee2MQTT - an “unofficial” integration, which is not that easy to set-up, especially when Home Assistant is installed in a Docker container, because you’ll need to run some more Docker containers with various required components and make them all talk to each other. On the bright side though, it has a wider range of supported devices/sensors.

ZHA looks like the way to go, but where is the fun in that, if it just works out of the box, so of course I went the Zigbee2MQTT way.

Eclipse Mosquitto

Zigbee coordinator manages Zigbee traffic from/to devices, but it also needs to be able to talk to Home Assistant and the other way around. For that type of communication it uses MQTT protocol, so we will need an MQTT broker.

One such broker is Eclipse Mosquitto. It can be installed as a Docker container too, and the image is here. Setting up a container:

$ sudo docker run -d \
    --name mosquitto \
    --restart=unless-stopped \
    -v /root/mosquitto/config:/mosquitto/config \
    -v /root/mosquitto/data:/mosquitto/data \
    -v /root/mosquitto/log:/mosquitto/log \
    eclipse-mosquitto

Again, the -v arguments here tell the container which host paths will be mapped to which container paths, so when Mosquitto binary inside container will need to read its “local” /mosquitto/config/mosquitto.conf file, it will actually read the /root/mosquitto/config/mosquitto.conf file in the host filesystem.

Speaking about the config, it needs to be created:

$ sudo nano /root/mosquitto/config/mosquitto.conf
persistence true
persistence_location /mosquitto/data
log_dest file /mosquitto/log/mosquitto.log

Now start the container (not sure why it hasn’t started on docker run, but probably because of the missing config):

$ sudo docker ps
CONTAINER ID   IMAGE                                          COMMAND   CREATED        STATUS       PORTS     NAMES
7e9bc60ef0f8   ghcr.io/home-assistant/home-assistant:stable   "/init"   19 hours ago   Up 4 hours             homeassistant

$ sudo docker start mosquitto
mosquitto

$ sudo docker ps
CONTAINER ID   IMAGE                                          COMMAND                  CREATED         STATUS         PORTS      NAMES
5c0503542640   eclipse-mosquitto                              "/docker-entrypoint.…"   4 minutes ago   Up 5 seconds   1883/tcp   mosquitto
7e9bc60ef0f8   ghcr.io/home-assistant/home-assistant:stable   "/init"                  19 hours ago    Up 4 hours                homeassistant
osmc@vero:~$ mc

Move it to the same network where homeassistant container is:

$ sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mosquitto
172.17.0.2

$ sudo docker network inspect hub
[
    {
        "Name": "hub",
        "...": "...",
        "Containers": {
            "7e9bc60ef0f894eec68eaf784b06c0d4605ee5dc52d5733c9f0394de67a3943c": {
                "Name": "homeassistant",
                "EndpointID": "5a8ddd308055e189ec9a4a37705b150b00aea88e042c2e4d085ec585c35779c8",
                "MacAddress": "02:42:ac:12:00:64",
                "IPv4Address": "172.18.0.100/16",
                "IPv6Address": ""
            }
        },
        "...": {}
    }
]

$ sudo docker network disconnect bridge mosquitto
$ sudo docker network connect hub mosquitto

$ sudo docker network inspect hub
[
    {
        "Name": "hub",
        "...": "...",
        "Containers": {
            "5c0503542640bfc3f8413deb6e1b5c785476783ddbaa385aad4993be28315484": {
                "Name": "mosquitto",
                "EndpointID": "267141eaccc22d8c3f5e97f38069ca83187f8747b9022079eb03eb7140178df4",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            },
            "7e9bc60ef0f894eec68eaf784b06c0d4605ee5dc52d5733c9f0394de67a3943c": {
                "Name": "homeassistant",
                "EndpointID": "5a8ddd308055e189ec9a4a37705b150b00aea88e042c2e4d085ec585c35779c8",
                "MacAddress": "02:42:ac:12:00:64",
                "IPv4Address": "172.18.0.100/16",
                "IPv6Address": ""
            }
        },
        "...": {}
    }
]

$ sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mosquitto
172.18.0.2

…Or you could’ve just provided --network hub to docker run on setting up the container.

There is no need to assign a static IP address to it, because homeassistant container will be addressing it by hostname. And actually, let’s check that it is in fact reachable that way:

$ sudo docker exec -it homeassistant bash

7e9bc60ef0f8:/config# ping -c 2 172.18.0.2
PING 172.18.0.2 (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.349 ms
64 bytes from 172.18.0.2: seq=1 ttl=64 time=0.327 ms

7e9bc60ef0f8:/config# ping -c 2 mosquitto
PING mosquitto (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.293 ms
64 bytes from 172.18.0.2: seq=1 ttl=64 time=0.297 ms

7e9bc60ef0f8:/config# nc mosquitto 1883 < /dev/null
7e9bc60ef0f8:/config# echo $?
1

7e9bc60ef0f8:/config# exit

So it is reachable, however the port 1883 (default MQTT port) isn’t open (as netcat returns non-zero exit code). At first I didn’t understand, why is that, because that port is listed here:

$ sudo docker container ls --format "table {{.ID}}\t{{.Names}}\t{{.Ports}}" -a
CONTAINER ID   NAMES           PORTS
8941202da971   mosquitto       1883/tcp
7e9bc60ef0f8   homeassistant

At the same time, checking for 8123 port on homeassistant from mosquitto container succeeds, even though this port is not listed in the table above:

$ sudo docker exec -it mosquitto sh

/ # nc homeassistant 8123 < /dev/null
/ # echo $?
0

/ # exit

I’m guessing that the ports listed in the table above are just what is specified in the Docker image, which doesn’t necessarily mean that they will be actually open.

Anyway, by default Mosquitto doesn’t seem to be listening on any ports, so you need to explicitly set them in the config:

$ sudo nano /root/mosquitto/config/mosquitto.conf
persistence true
persistence_location /mosquitto/data
log_dest file /mosquitto/log/mosquitto.log

listener 1883 0.0.0.0

listener 9001 0.0.0.0
protocol websockets

and restart the Mosquitto container:

$ sudo docker restart mosquitto

Check the ports again:

$ sudo docker exec -it homeassistant sh

/config # nc mosquitto 1883 < /dev/null
/config # echo $?
0
/config # nc mosquitto 9001 < /dev/null
/config # echo $?
0

/config # exit

Now netcat returns zero for both 1883 and 9001, so the ports are open.

If you had a question about why the listener is set to 0.0.0.0 in the config, as you probably thought that this will make the ports exposed from the host, then worry not, you just got confused like I did, and 0.0.0.0 here is this particular container within the hub network, so it is not the ports of the actual host. You can check for yourself what will happen if you set the listener to 127.0.0.1:

$ sudo nano /root/mosquitto/config/mosquitto.conf
# ...

listener 9001 127.0.0.1
protocol websockets
$ sudo docker restart mosquitto

$ sudo docker exec -it homeassistant sh

/config # nc mosquitto 9001 < /dev/null
/config # echo $?
1

/config # exit

As you can see, binding listener to 127.0.0.1 made that port 9001 to be open only on localhost of the container itself, so now that port is not available for other containers and it isn’t available for the host either (let alone being exposed from the host). And when listener is bound to 0.0.0.0, then this port becomes available for other containers (in that Docker network) and for the host too, but it still isn’t exposed from the host (which you can verify by running ss -lntup). Perfectly splendid, isn’t it. By the way, don’t forget to revert that listener back to 0.0.0.0.

Finally, Mosquitto instance should have authentication:

$ sudo docker exec -it mosquitto sh

/ # mosquitto_passwd -c /mosquitto/config/pswds SOME-USERNAME
Password:
Reenter password:

/ # exit

$ sudo nano /root/mosquitto/config/mosquitto.conf
# ...

listener 9001 0.0.0.0
protocol websockets

allow_anonymous false
password_file /mosquitto/config/pswds
$ sudo docker restart mosquitto

Now it is ready to be added to Home Assistant. Open SettingsDevices & ServicesIntegrations and click on Add Integration. Search for mqtt:

Home Assistant, searching for MQTT integration

select MQTT:

Home Assistant, searching for MQTT integration

and configure it:

Home Assistant, MQTT configuration

As you can see, since containers are reachable by their hostnames in our network, instead of specifying the mosquitto’s container IP address we can just use its hostname.

On successful configuration the Mosquitto logs (/root/mosquitto/log/mosquitto.log) should get something like:

1721766257: New connection from 172.18.0.100:33859 on port 1883.
1721766257: New client connected from 172.18.0.100:33859 as SOME-ID-HERE (p2, c1, k60, u'SOME-USERNAME').
1721766347: Saving in-memory database to /mosquitto/data/mosquitto.db

To test that it actually works, go to MQTT settings in your Home Assistant and subscribe to everything by setting a special # topic. Then open mosquitto container shell and publish a message with mosquitto_pub:

$ sudo docker exec -it mosquitto sh

/ # mosquitto_pub -h 127.0.0.1 -t homeassistant/switch/1/on -m "Switch is ON"
Connection error: Connection Refused: not authorised.
Error: The connection was refused.

/ # mosquitto_pub -h 127.0.0.1 -t homeassistant/switch/1/on -m "Switch is ON" -u SOME-USERNAME -P SOME-PASSWORD

/ # exit

You should then see that message in the Home Assistant interface:

Home Assistant, testing MQTT

Zigbee2MQTT

Now when MQTT broker is running, it’s time to launch Zigbee2MQTT. Create a config:

$ sudo mkdir -p /root/zigbee2mqtt/{data,logs}
$ sudo nano /root/zigbee2mqtt/data/configuration.yaml
# Home Assistant integration (MQTT discovery)
homeassistant: true

# https://zigbee2mqtt.io/guide/configuration/frontend.html
frontend:
  port: 8080
  # the frontend webpage opens without authentication, but it will be "empty" until you enter
  # this authentication token in the browser prompt
  auth_token: YOUR-AUTH-TOKEN

# allow new devices to join (don't forget to set to `false` after you add all your devices)
permit_join: true

mqtt:
  # MQTT base topic for zigbee2mqtt MQTT messages
  base_topic: zigbee2mqtt
  # MQTT server URL (can use the hostname here too)
  server: 'mqtt://mosquitto'
  # MQTT server authentication
  user: SOME-USERNAME
  password: SOME-PASSWORD

serial:
  # where Zigbee adapter is connected to
  port: /dev/ttyACM0

advanced:
  # that way it will generate a random key on the first launch and will put it here
  network_key: GENERATE

  # debug / info / warning / error
  log_level: info
  # log levels for certain namespaces to reduce the traffic
  log_namespaced_levels:
    z2m:mqtt: warning
  log_directory: /app/logs
  log_file: zigbee2mqtt.log
  # rotate log every 10 MB around 3 files
  log_rotation: true
  log_output:
    - console
    - file

Configure and run a Docker container:

$ sudo docker run -d \
    --name zigbee2mqtt \
    --network hub \
    --restart=unless-stopped \
    --device=/dev/serial/by-id/YOUR-ZIGBEE-ADAPTER:/dev/ttyACM0 \
    -v /root/zigbee2mqtt/data:/app/data \
    -v /root/zigbee2mqtt/logs:/app/logs \
    -e TZ=Europe/Amsterdam \
    koenkk/zigbee2mqtt

If you specified incorrect --device path, or if the device (Zigbee adapter/coordinator) is not connected, then you’ll see errors like these in the log:

$ sudo docker container logs zigbee2mqtt
...
info:     z2m: Starting Zigbee2MQTT version 1.39.0 (commit #0326926)
info:     z2m: Starting zigbee-herdsman (0.50.1)
error:    zh:zstack:znp: Failed to determine if path is valid: 'Error: ENOENT: no such file or directory, lstat '/dev/ttyACM0''
info:     zh:zstack:znp: Opening SerialPort with {"path":"/dev/ttyACM0","baudRate":115200,"rtscts":false,"autoOpen":false}
error:    z2m: Error while starting zigbee-herdsman
error:    z2m: Failed to start zigbee
error:    z2m: Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions
error:    z2m: Exiting...
error:    z2m: Error: Error: No such file or directory, cannot open /dev/ttyACM0

If you are sure that you specified everything correctly and your device is connected, then check that you have /dev/serial/ at all:

$ ls -lah /dev/serial

If you don’t, then you might need to edit /usr/lib/udev/rules.d/60-serial.rules and apply this fix.

For me that wasn’t the case - I just haven’t connected the adaptor yet. After I did, here’s what dmesg printed out:

$ sudo dmesg -w
...
usb 1-2: new full-speed USB device number 5 using xhci-hcd
usb 1-2: New USB device found, idVendor=1a86, idProduct=55d4
usb 1-2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
usb 1-2: Product: SONOFF Zigbee 3.0 USB Dongle Plus V2
usb 1-2: Manufacturer: ITEAD
usb 1-2: SerialNumber: SERIAL-NUMBER-HERE
cdc_acm 1-2:1.0: ttyACM0: USB ACM device

and I got it in /dev/serial/:

$ ls -lah /dev/serial/by-id/
drwxr-xr-x 2 root root 60 Aug  5 10:17 .
drwxr-xr-x 4 root root 80 Aug  5 10:17 ..
lrwxrwxrwx 1 root root 13 Aug  5 10:17 usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_SERIAL-NUMBER-HERE-if00 -> ../../ttyACM0

$ ls -lah /dev/serial/by-path/
drwxr-xr-x 2 root root 60 Aug  5 10:17 .
drwxr-xr-x 4 root root 80 Aug  5 10:17 ..
lrwxrwxrwx 1 root root 13 Aug  5 10:17 platform-xhci-hcd.0.auto-usb-0:2:1.0 -> ../../ttyACM0

That usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_SERIAL-NUMBER-HERE-if00 value is what you need to put into --device mapping on creating/running the container. Since I have already created a container without providing a correct value and I don’t know how to update an already existing container with a correct mapping, I just re-created it:

$ sudo docker stop zigbee2mqtt
$ sudo docker rm zigbee2mqtt

$ sudo docker run -d \
    --name zigbee2mqtt \
    --network hub \
    --restart=unless-stopped \
    --device=/dev/serial/by-id/usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_SERIAL-NUMBER-HERE-if00:/dev/ttyACM0 \
    -v /root/zigbee2mqtt/data:/app/data \
    -v /root/zigbee2mqtt/logs:/app/logs \
    -e TZ=Europe/Amsterdam \
    koenkk/zigbee2mqtt

Successful launch will manifest itself in the log like this:

info:     z2m: Starting Zigbee2MQTT version 1.39.0 (commit #0326926)
info:     z2m: Starting zigbee-herdsman (0.50.1)
info:     zh:zstack:znp: Opening SerialPort with {"path":"/dev/ttyACM0","baudRate":115200,"rtscts":false,"autoOpen":false}
info:     zh:zstack:znp: Serialport opened
info:     zh:zstack:znp: Writing CC2530/CC2531 skip bootloader payload
info:     zh:zstack:znp: Skip bootloader for CC2652/CC1352

…however, after that I got a different error:

error:    z2m: Error while starting zigbee-herdsman
error:    z2m: Failed to start zigbee
error:    z2m: Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions
error:    z2m: Exiting...
error:    z2m: Error: Failed to connect to the adapter (Error: SRSP - SYS - ping after 6000ms)
    at ZStackAdapter.start (/app/node_modules/zigbee-herdsman/src/adapter/z-stack/adapter/zStackAdapter.ts:101:27)
    at Controller.start (/app/node_modules/zigbee-herdsman/src/controller/controller.ts:129:29)
    at Zigbee.start (/app/lib/zigbee.ts:63:27)
    at Controller.start (/app/lib/controller.ts:139:27)
    at start (/app/index.js:154:5)

Possible reasons are listed here, and I suspected that in my case it was an obsolete firmware, but then I saw this video, which is exactly about SONOFF Zigbee 3.0 USB Dongle Plus-E, and there I noticed the adapter: ezsp setting, which I did not have in my config. So I added it:

# ...

serial:
  # where Zigbee adapter is connected to
  port: /dev/ttyACM0
  # if you have SONOFF Zigbee 3.0 USB Dongle Plus-E
  adapter: ezsp

And then it finally launched (almost) fine:

info:     z2m: Starting Zigbee2MQTT version 1.39.0 (commit #0326926)
info:     z2m: Starting zigbee-herdsman (0.50.1)
warning:     zh:ezsp: Deprecated driver 'ezsp' currently in use, 'ember' will become the officially supported EmberZNet driver in next release. If using Zigbee2MQTT see https://github.com/Koenkk/zigbee2mqtt/discussions/21462
error:     zh:ezsp:ezsp: Failure to init network
info:     zh:ezsp:driv: Form network
info:     zh:controller: Wrote coordinator backup to '/app/data/coordinator_backup.json'
info:     z2m: zigbee-herdsman started (reset)
info:     z2m: Coordinator firmware version: '{"meta":{"maintrel":"3 ","majorrel":"6","minorrel":"10","product":8,"revision":"6.10.3.0 build 297"},"type":"EZSP v8"}'
info:     z2m: Currently 0 devices are joined:
warning:     z2m: `permit_join` set to  `true` in configuration.yaml.
warning:     z2m: Allowing new devices to join.
warning:     z2m: Set `permit_join` to `false` once you joined all devices.
info:     z2m: Zigbee: allowing new devices to join.
info:     z2m: Connecting to MQTT server at mqtt://mosquitto
info:     z2m: Connected to MQTT server
info:     z2m: Zigbee2MQTT started!

One of the warnings says that ezsp is a deprecated driver, and instead it should be ember. So I tried that:

# ...

serial:
  # where Zigbee adapter is connected to
  port: /dev/ttyACM0
  # if you have SONOFF Zigbee 3.0 USB Dongle Plus-E
  adapter: ember

but it failed to launch:

info:     z2m: Starting Zigbee2MQTT version 1.39.0 (commit #0326926)
info:     z2m: Starting zigbee-herdsman (0.50.1)
info:     zh:ember: Using default stack config.
info:     zh:ember: ======== Ember Adapter Starting ========
info:     zh:ember:ezsp: ======== EZSP starting ========
info:     zh:ember:uart:ash: ======== ASH NCP reset ========
info:     zh:ember:uart:ash: RTS/CTS config is off, enabling software flow control.
info:     zh:ember:uart:ash: Serial port opened
info:     zh:ember:uart:ash: ======== ASH starting ========
error:     zh:ember:uart:ash: Received ERROR from NCP while connecting, with code=ERROR_EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT.
error:     zh:ember:uart:ash: ASH disconnected | NCP status: ASH_NCP_FATAL_ERROR
error:     zh:ember:uart:ash: Error while parsing received frame, status=ASH_NCP_FATAL_ERROR.
info:     zh:ember:uart:ash: ======== ASH NCP reset ========
info:     zh:ember:uart:ash: ======== ASH starting ========
info:     zh:ember:uart:ash: ======== ASH connected ========
info:     zh:ember:uart:ash: ======== ASH started ========
info:     zh:ember:ezsp: ======== EZSP started ========
error:     z2m: Error while starting zigbee-herdsman
error:     z2m: Failed to start zigbee
error:     z2m: Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions
error:     z2m: Exiting...
error:     z2m: Error: NCP EZSP protocol version of 8 does not match Host version 13
    at EmberAdapter.emberVersion (/app/node_modules/zigbee-herdsman/src/adapter/ember/adapter/emberAdapter.ts:1714:19)
    at EmberAdapter.initEzsp (/app/node_modules/zigbee-herdsman/src/adapter/ember/adapter/emberAdapter.ts:893:9)
    at EmberAdapter.start (/app/node_modules/zigbee-herdsman/src/adapter/ember/adapter/emberAdapter.ts:2722:24)
    at Controller.start (/app/node_modules/zigbee-herdsman/src/controller/controller.ts:129:29)
    at Zigbee.start (/app/lib/zigbee.ts:63:27)
    at Controller.start (/app/lib/controller.ts:139:27)
    at start (/app/index.js:154:5)

From what I see here, to make it work with ember, apparently I need flash my Zigbee adapter with a different/newer firmware. However, for now I decided not to, and I just reverted the config back to ezsp, as that one seemed to be already working as it is.

To access the frontend I exposed its port (8080 by default) from the host the same way I did it with Home Assistant container (static IP and reverse proxy):

$ sudo docker network disconnect hub zigbee2mqtt
$ sudo docker network connect --ip 172.18.0.101 hub zigbee2mqtt
$ sudo docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' zigbee2mqtt
172.18.0.101

$ sudo cp /etc/nginx/sites-available/{homeassistant,zigbee2mqtt}
$ sudo nano /etc/nginx/sites-available/zigbee2mqtt
map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

server {
    # again, doesn't have to be exactly 8080
    listen 8080 ssl;

    ssl_certificate /opt/certificates/fullchain.pem;
    ssl_certificate_key /opt/certificates/key.pem;

    location / {
        proxy_pass http://172.18.0.101:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
$ sudo ln -s /etc/nginx/sites-{available,enabled}/zigbee2mqtt
$ sudo systemctl restart nginx.service

Once I’ve logged-in to my Zigbee2MQTT frontend, I could see that my Zigbee adapter/coordinator revision is 6.10.3.0 build 297:

Zigbee2MQTT frontend

but I have no idea how to check if that is the latest available (or how to update it to the latest). I’ve seen several GiHub repositories with some firmwares, but I can’t tell which of them is the official one, and in general distributing firmware binaries via GitHib seems very bizarre to me. Either way, there is no pressure, as it seems to work fine with the current firmware.

Now you can start adding devices to grow your Zigbee network. To add a new device, put it (the device) into pairing mode and wait for it to appear on the Devices page of your Zigbee2MQTT frontend:

Zigbee2MQTT, discovered device

Once the device is paired, Zigbee adapter/coordinator hardware will be communicating with it over Zigbee and will be forwarding its data to Home Assistant via MQTT; and the other way around, commands from Home Assistant well be going through MQTT broker to Zigbee2MQTT, which will be transmitting them to devices over Zigbee by coordinator.

Below there will be examples for some of the devices that I have.

Philips Hue Smart Plug

Philips has a whole family of smart home devices, which is called Philips Hue, and out of those I only have a Philips Hue Smart Plug for now:

Philips Hue Smart Plug

Philips Hue Smart Plug

To put it into pairing mode, plug it into a power socket, wait for the front indicator to turn off, press and hold the button on the side and wait for the indicator to blink with orange for 5 or more times. Shortly after that it should appear in the list of devices in Zigbee2MQTT frontend.

The pairing happens automatically, you don’t need to do anything, just watch the pop-ups. Once the device is paired, here’s how its page will look like (here I have already renamed it):

Zigbee2MQTT, Philips Hue Smart plug

To easily find it in Home Assistant, rename it from the default name to something meaningful and update the Home Assistant entity ID too:

Zigbee2MQTT, renaming a device

Then it should appear in Home Assistant list of devices under that exact name:

Philips Hue Smart plug in Home Assistant devices

At this point you can already turn it on and off from Home Assistant and use it in your automation routines.

Sadly, this particular “smart” plug is rather “dumb”, as it only has on/off capabilities with no energy meter/counter:

Philips Hue Smart Plug as a Home Assistant device

But that is actually fine, because it costs less than the alternatives with energy meter - 300 NOK versus 400+ NOK - which builds up to a significant difference if you need to buy several of those.

Frient Smart Plug Mini 2

There is another smart plug - Frient Smart Plug Mini 2:

Frient Smart Plug Mini 2

Frient Smart Plug Mini 2

And this one is equiped with an energy meter/counter, so it is “smarter” than the one from Philips, but it is also more expensive (about 500 NOK) and unglier too, so I don’t think I’ll buy more of these.

The pairing should be trivial: when it is not part of any network, it enters the pairing mode automatically. If not, try pressing the button on the side for 5 or more seconds, or/and re-plug it to a power socket.

Once Zigbee2MQTT pairs with it, here are the metrics it will expose:

Home Assistant, Frient Smart Plug Mini 2 sensors

So it is immediate power consumption, total amount of power already consumed, voltage, frequency and others. As with any other value in Home Assistant, you can get a historical chart of changes, for example here’s one for voltage:

Home Assistant, Frient Smart Plug Mini 2 voltage chart

So that’s already quite a lot to play with, as you can create scripts and automations to perform various actions based on any of those metrics.

IKEA Tradfri E27

As it turned out, IKEA bulbs with a remote control, which I have already been using for a couple of years, are also Zigbee-enabled, so they can be connected to a Zigbee network without their remote control:

IKEA Tradfri E27

To put a bulb into pairing mode, turn it off and on for 5 times, and on the 6th time keep it on. It should start doing a certain lighting pattern, meaning that it is now discoverable by a Zigbee coordinator.

Once added, here’s how it looks in Home Assistant:

IKEA TRADFRI E27 sensors in Home Assistant

The controls are great, you can set not only brightness but the color temperature too, plus there are several effects available:

IKEA TRADFRI E27 controls in Home Assistant

As a very nice surprise, these bulbs also act as Zigbee signal repeaters/extenders, so they allow you to place other devices farther away from the coordinator/hub. And I forgot to mention that smart power plugs (Philips and Frient) also act as network extenders, so that is probably the case for many power-connected devices.

Having smart bulbs (and/or dumb lamps connected to smart plugs), one can create the following basic automation for turning on the (appropriately labeled) lights in one of the rooms on schedule:

Home Assistant, automation for turning on the lights on time

It will work with bulbs and plugs, despite them being different types of devices, because they all support switch actions.

Here’s also this automation as code:

alias: Turn ON lights on 2nd floor
description: ""
trigger:
  - platform: time
    at: "20:12:00"
condition: []
action:
  - service: switch.turn_on
    metadata: {}
    data: {}
    target:
      area_id: living_room
      label_id: lights
mode: single

And it will be trivial to create a similar automation for turning the lights off.

nimly Connect Module

I’ve got a nimly Touch Pro lock with a nimly Connect Module. The module allows you to control the lock remotely, so you can lock/unlock it, change some settings, get information about what method was last used to unlock it (fingerprint, token, pin-code) and which user did it.

The lock itself is great, but I’ve had some problems with the module. It is actually my 3rd module already, as the first two had certain defects (first one was loosing connection and second one was draining the batteries), and nimly people are graciously sending me replacements. The current module that I got has firmware build date 20240625 with version 4.7.79, and finally everything seems to be good with this one.

Initially I’ve been using the modules with Homely, but I wasn’t entirely satisfied with the functionality exposed in Homely application, so I decided to connect it to Home Assistant instead. The pairing with Zigbee2MQTT went fine, the module got interviewed and marked as supported:

nimly Touch Pro in Zigbee2MQTT

I also got it in Home Assistant, but nothing seemed to actually work. The commands that I tried to send to it (such as lock/unlock) were failing, as there was no reaction on the lock itself, and the logs had errors like:

zh:ezsp:ezsp: Frame changeSourceRouteHandler parsing error: RangeError: Attempt to access memory outside buffer bounds at new NodeError (node:internal/errors:405:5) at boundsError (node:internal/buffer:86:11) at Buffer.readUInt16LE (node:internal/buffer:245:5) at Buffer.readUIntLE (node:internal/buffer:182:17) at Function.deserialize (/app/node_modules/zigbee-herdsman/src/adapter/ezsp/driver/types/basic.ts:19:67) at new EZSPFrameData (/app/node_modules/zigbee-herdsman/src/adapter/ezsp/driver/ezsp.ts:180:54) at /app/node_modules/zigbee-herdsman/src/adapter/ezsp/driver/ezsp.ts:154:23 at Array.every (<anonymous>) at Function.createFrame (/app/node_modules/zigbee-herdsman/src/adapter/ezsp/driver/ezsp.ts:148:15) at Ezsp.onFrameReceived (/app/node_modules/zigbee-herdsman/src/adapter/ezsp/driver/ezsp.ts:439:35)
zh:ezsp:ezsp: Unparsed frame 0xc4. Skipped
z2m: Publish 'set' 'state' to 'nimly Touch Pro' failed: 'Error: ZCL command IEEE-ADDRESS-HERE/11 closuresDoorLock.lockDoor({"pincodevalue":""}, {"timeout":10000,"disableResponse":false,"disableRecovery":false,"disableDefaultResponse":true,"direction":0,"srcEndpoint":null,"reservedBits":0,"manufacturerCode":null,"transactionSequenceNumber":null,"writeUndiv":false}) failed (Timeout - 57813 - 11 - 52 - 257 - 0 after 10000ms)'

Also its sensors were reporting None in Home Assistant:

nimly Touch Pro in Home Assistant, no data

Before trying anything else I tried to simply re-pair it (force-remove, re-insert the batteries and add it back again), and this time it got successfully added with everything working (even though there were still the same errors in the logs):

nimly Touch Pro in Home Assistant

Now that there is a possibility to remotely control such a critical piece of hardware as main door lock, you can quite literally be hacked - just like in the movies. How exciting is that!

Aqara Cube T1 Pro

That little thing is fantastic:

Aqara Cube T1 Pro

Say you’d like to have a physical button, which would control the lights, so you could just press it instead of reaching to your phone/computer with Home Assistant client application/browser. And then maybe you’d also like to have a dimmer for the lights, and maybe yet another button for controlling a power plug, and so on and so on. Every such Zigbee-capable button/dimmer would cost you some noticable amount of money, plus they would be piling up on your table, so that doesn’t scale very well, does it.

But what if instead of having several such buttons/dimmers you could have just one device that could somehow act as several “buttons”. And Aqara Cube is exactly that: it can trigger at the very least 18 actions (3 for each side: tapping, rotating clockwise and rotating counterclockwise) - but actually more, because there are also actions for pushing, resting, holding, throwing, shaking, flipping 90/180 degrees and more. And all that for the price of less than one physical button.

To state the obvious, you do not need the Aqara hub or anything else to interface with it, as Zigbee2MQTT can pair with it directly just fine. Take off the top side cover/plate and hold the link button for 5 seconds. Once it is paired, don’t put the plate back just yet - I would recommend you to switch the cube to Action mode. That is done by sending the “operation” command to it, either from Zigbee2MQTT device page or from Home Assistant device page. For this to take effect immediately (otherwise it “syncs” once per hour?), press the link button once, and then you can put the plate back.

Here’s a very basic automation that you can make with the cube:

Aqara Cude automation for toggling lights

So whenever I double-tap the cube on the table (or wherever it is), while its top side is 1, all the devices labeled with lights in the Living room area will be toggled on/off.

Here it is a code too:

alias: 💡 1 | Toggle lights on 2nd floor
description: ""
trigger:
  - platform: device
    domain: mqtt
    device_id: AQARA-CUBE-DEVICE-ID-HERE
    type: action
    subtype: tap
condition:
  - condition: state
    entity_id: sensor.aqara_cube_t1_pro_side
    state: "1"
action:
  - service: switch.toggle
    metadata: {}
    data: {}
    target:
      area_id: living_room
      label_id: lights
mode: single

There will be some more automation examples later.

Home Assistant Community Store

To further extend Home Assistant capabilities, you can add Home Assistant Community Store (HACS) to it.

The HACS is supposed to be installed into /path/to/home-assistant/config/custom_components/, which in case of Docker container would mean creating this folder inside the container, but fortunately we have already mounted/mapped this path on container creation, and it points to /root/homeassistant/ in the host filesystem.

As for the installation itself, yet again it is expected that you will download and run their script, but this time I couldn’t tolerate that, so I just picked out the relevant commads from it. Essentially, it just downloads a ZIP archive and extracts it to /path/to/home-assistant/config/custom_components/hacs/:

$ cd /tmp
$ wget "https://github.com/hacs/integration/releases/latest/download/hacs.zip"

$ export HOMEASSISTANT_PATH="/root/homeassistant"
$ sudo ls -L1a $HOMEASSISTANT_PATH
automations.yaml
blueprints
.cloud
configuration.yaml
deps
.HA_VERSION
home-assistant.log
home-assistant.log.1
home-assistant.log.fault
home-assistant_v2.db
home-assistant_v2.db-shm
home-assistant_v2.db-wal
scenes.yaml
scripts.yaml
secrets.yaml
.storage
tts
$ sudo cat $HOMEASSISTANT_PATH/.HA_VERSION && echo
2024.7.3

$ sudo mkdir -p "$HOMEASSISTANT_PATH/custom_components/hacs"
$ sudo unzip /tmp/hacs.zip -d $HOMEASSISTANT_PATH/custom_components/hacs

Restart the container:

$ sudo docker restart homeassistant

And then you should get HACS available in the integrations list:

HACS integration

I did not enable experimental features, just in case:

HACS integration acknowledgements

The next step was concerning, as it required to authorize with GitHub. I have no idea why does it need that, but at least it seems to only request “limited access to your public data”, which is probably fine, so I authorized it:

HACS integration, GitHub authorization

After configuration was done, the HACS entry was added to the side panel:

HACS on side panel in Home Assistant

…although without icon at first, but after reloading the page with clearing cache it did appear. This “reloading with clearing the cache” you will very often see being mentioned in Home Assistant documentation and forum.

With HACS being integrated in your Home Assistant, you can add even more devices and sensors to it.

Dyson HP09

I have a Dyson HP09 purifier/fan/heater:

Description

And I wanted to check if it is possible to get its sensors data and maybe control the device too. Turns out, someone already did that.

To install it, search for “dyson” in HACS. After the installation:

HACS, installing Dyson

and Home Assistant restart, Dyson became available in the integrations/devices.

To configure it you will need to provide its IP address, which means that you’ll need to assign a static one in your home network DHCP, and also Vero V (or whatever you are using as your Home Assistant hub) and Dyson both need to be in the same network (which is a bummer, because I prefer to keep such devices in a separate isolated network). If it keeps failing to connect to the Dyson, that could be because of the old DHCP lease, so try to power off the Dyson completely, unplug the power cable, wait a bit and power it back on.

In connection methods I chose the Dyson account:

HACS, configuring Dyson with an account

Once the device is added, you can remove your Dyson account credentials from the integration entries and restart Home Assistant. From what I understood, Dyson runs its own MQTT server, which this integration uses to communicate with the device over the local network (no Zigbee involved, obviously), so without the internet, which is great.

At first Home Assistant was showing just some basic controls, like the fan speed, direction and oscillation:

Home Assistant, Dyson basic controls

which was quite good already, but still pretty underwhelming, as I was expecting to get all of its marvelous data such as temperature, humidity, air quality metrics and the rest. But then a couple of minutes later I got everything, so it probably just doesn’t arrive all at once:

Home Assistant, Dyson sensors

At this point you should once again think about securing your Home Assistant instance, because a malicious person may be able to remotely switch your fan into the heating mode, and that can be up to 2.5 kWh of energy consumption alone (and with great power comes great electricity bill), not to mention your room being overheated, which might in turn cause some other damage.

Controlling Dyson with Aqara Cube

Here is another example of how great the Aqara Cube is. So I want to toggle Auto/Normal mode of the fan by tapping the cube when the side 6 is up:

Aqara Cube automation, toggling Dyson mode

Here’s the same as code:

alias: 🪭 6 | Toggle Auto/Normal mode
description: ""
trigger:
  - platform: device
    domain: mqtt
    device_id: AQARA-CUBE-DEVICE-ID-HERE
    type: action
    subtype: tap
condition:
  - condition: state
    entity_id: sensor.aqara_cube_t1_pro_side
    state: "6"
action:
  - if:
      - condition: state
        entity_id: fan.dyson_hp09
        attribute: preset_mode
        state: Auto
    then:
      - service: fan.set_preset_mode
        metadata: {}
        data:
          preset_mode: Normal
        target:
          device_id: DYSON_DEVICE_ID_HERE
    else:
      - service: fan.set_preset_mode
        metadata: {}
        data:
          preset_mode: Auto
        target:
          device_id: DYSON_DEVICE_ID_HERE
mode: single

And it works, it bloody works, what sorcery is this.

I have also created automations for setting certain fan speeds on rotating the cube with the 6 side up, but those are too trivial to be shown here, you can easily create those yourself.

Zaptec

I also have a Zaptec Go charger for my electric car:

Zaptec Go

along with Zaptec Sense (HAN-port module):

Zaptec Sense

And since Zaptec has a REST API, I was hoping that it would be possible to get some of the charging/power metrics in Home Assistant. And what would you know, someone has already done that too.

This integration is also installed via HACS by searching for “zaptec”, and as usual you will need to restart Home Assistant after the installation. After that it will become available in the list of integrations.

Since neither(?) of Zaptec devices expose a local API, all the REST API HTTP requests are sent to Zaptec server over the internet, so there is no Zigbee involved here either. For the integration to work you need to provide it with your Zaptec Portal credentials:

Home Assistant, Zaptec integration configuration

I created a separate user for this, hoping that it will be a non-owner type of user, but unfortunately no, with the current set of roles you’ll have to grant that user the owner role too:

Zaptec user roles

After the successful configuration you will get a lot of metrics:

Home Assistant, Zaptec sensors

…But unfortunately there is no data from Zaptec Sense. Among other things it provides a total household power consumption, which you can view at your Zaptec Portal:

Zaptec Sense power consumption data

And it would be very useful to have that data in Home Assistant too.

I’ve submitted a feature request for this, but it isn’t really within the scope of this particular integration, so I will probably have to implement it myself.

Nord Pool electricity prices

In some countries it is possible to get per-hour electricity prices for today an tomorrow, which is a nice metric to have in Home Assistant. Surely enough, someone has already done that too.

Installation procedure is the same, just search for “nordpool” in HACS. For configuration you will need to provide your zone/region (here are the norwegian zones, and you should have yours stated in the electricity invoices that you receive from your power supplier):

Home Assistant, Nord Pool integration configuration

But getting plain price values is no fun, we want a chart, and for that there is a yet another HACS repository - ApexCharts Card. Having installed that, you will be able to create a chart like this one:

Home Assistant, Nord Pool data with ApexCharts

The code for it:

type: custom:apexcharts-card
graph_span: 46h
header:
  title: Цены на электричество по часам
  show: true
  show_states: true
  colorize_states: true
span:
  start: day
now:
  show: true
  label: Сейчас
  color: '#0074D9'
experimental:
  color_threshold: true
series:
  - entity: sensor.nordpool_kwh_oslo_nok_2_10_025
    name: Цена
    type: column
    float_precision: 2
    color_threshold:
      - value: 0
        color: '#2ECC40'
      - value: 0.4
        color: '#FFDC00'
      - value: 0.6
        color: '#FF4136'
    show:
      extremas: true
      header_color_threshold: true
      name_in_header: false
      in_header: raw
    data_generator: >
      return [...entity.attributes.raw_today,
      ...entity.attributes.raw_tomorrow].map((item) =>
        [Date.parse(item.start), item.value]);      
apex_config:
  chart:
    height: 400px
  tooltip:
    x:
      show: false

And not just that, you can also add some Markdown cards to print out the prices highlights, for example:

Home Assistant, Nord Pool data with Markdown

The code for it (based on someone else’s code that I found in this thread):

type: vertical-stack
cards:
  - type: markdown
    alignment: justify
    content: >-
      {% set priceListToday =
      state_attr('sensor.nordpool_kwh_oslo_nok_2_10_025', 'today')[00:23] %} {%
      set dateListToday = state_attr('sensor.nordpool_kwh_oslo_nok_2_10_025',
      'raw_today')[00:23] %} {% set minPriceToday = min(priceListToday) %} {%
      set minIndexToday = priceListToday.index(minPriceToday) %} {% set
      minDateTimeStrToday = dateListToday[minIndexToday].start | string %} {%
      set minDateTimeToday= strptime(minDateTimeStrToday[0:19], '%Y-%m-%d
      %H:%M:%S') %}
      Самая низкая цена сегодня: **{{ minPriceToday }}**
      NOK/kWh в **{{ minDateTimeToday.hour }}:00**.

      {% if state_attr('sensor.nordpool_kwh_oslo_nok_2_10_025','tomorrow') |
      length > 1 %} {% set priceListTomorrow =
      state_attr('sensor.nordpool_kwh_oslo_nok_2_10_025', 'tomorrow')[00:23] %}
      {% set dateListTomorrow =
      state_attr('sensor.nordpool_kwh_oslo_nok_2_10_025', 'raw_tomorrow')[00:23]
      %} {% set minPriceTomorrow = min(priceListTomorrow) %} {% set
      minIndexTomorrow = priceListTomorrow.index(minPriceTomorrow) %} {% set
      minDateTimeStrTomorrow = dateListTomorrow[minIndexTomorrow].start | string
      %} {% set minDateTimeTomorrow = strptime(minDateTimeStrTomorrow[0:19],
      '%Y-%m-%d %H:%M:%S') %}
      Самая низкая цена завтра: **{{ minPriceTomorrow
      }}** NOK/kWh в **{{ minDateTimeTomorrow.hour }}:00**. {% else %} *На
      завтра цен пока нет.* {% endif %}      
  - type: markdown
    alignment: justify
    content: >-
      {% set iterativesum = namespace(iter=[]) %} {% set lowestiter =
      namespace(kr=2) %} {% set timelowest = namespace(hr=2) %} {% set
      highestiter = namespace(kr=0) %} {% set timehighest = namespace(hr=0) %}
      {% set num_hours = 4 | int %} {% set nordpoolentity =
      'sensor.nordpool_kwh_oslo_nok_2_10_025' %} {% set timemapper = { 0:
      '00:00', 1 : '01:00', 2 : '02:00', 3 : '03:00', 4 : '04:00', 5 : '05:00',
      6 : '06:00', 7 : '07:00', 8 : '08:00', 9 : '09:00', 10 : '10:00', 11 :
      '11:00', 12 : '12:00', 13 : '13:00', 14 : '14:00', 15 : '15:00', 16 :
      '16:00', 17 : '17:00', 18 : '18:00', 19 : '19:00', 20 : '20:00', 21 :
      '21:00', 22 : '22:00', 23 : '23:00', 24 : '00:00', 25 : '01:00', 26 :
      '02:00', 27 : '03:00', 28 : '04:00', 29 : '05:00', 30 : '06:00', 31 :
      '07:00', 32 : '08:00', 33 : '09:00', 34 : '10:00', 35 : '11:00', 36 :
      '12:00', 37 : '13:00', 38 : '14:00', 39 : '15:00', 40 : '16:00', 41 :
      '17:00', 42 : '18:00', 43 : '19:00', 44 : '20:00', 45 : '21:00', 46 :
      '22:00', 47 : '23:00', 48 : '0:00', } %} {% set prices =
      namespace(price=[]) %} {% set prices.price = prices.price +
      state_attr(nordpoolentity, 'today') %} {%- if
      state_attr(nordpoolentity,'tomorrow') | length == 1 -%} *На завтра цен
      пока нет.* {% else %} {% set prices.price = prices.price +
      state_attr(nordpoolentity, 'tomorrow') %} {% endif %} {%- for n in
      range(prices.price|length -num_hours +1) -%} {%- set tempsum=
      namespace(temp=0) -%} {%- for i in range(num_hours) -%} {%- set
      tempsum.temp = tempsum.temp + prices.price[n+i] -%} {% endfor -%} {% set
      iterativesum.iter = iterativesum.iter + [tempsum.temp] -%} {% endfor -%}
      {% for iter in iterativesum.iter -%} {%- if loop.index > now().hour -%}
      {%- if iter < lowestiter.kr | float -%} {%- set lowestiter.kr = iter |
      float -%} {%- set timelowest.hr = loop.index -1 -%} {%- endif -%} {%- if
      iter > highestiter.kr | float -%} {%- set highestiter.kr = iter | float
      -%} {%- set timehighest.hr = loop.index -1 -%} {%- endif -%} {%- endif -%}
      {% endfor -%}

      Самые дешёвые **{{num_hours}}** часа {% if (timelowest.hr <
      24) %}сегодня{% else %}будут завтра{% endif %}: с
      **{{timemapper[timelowest.hr]}}** (*в среднем по
      **{{"%.2f"|format(lowestiter.kr/num_hours)}}** NOK/kWh*).

      Самые дорогие **{{num_hours}}** часа {% if (timehighest.hr < 24)
      %}сегодня{% else %}будут завтра{% endif %}: с
      **{{timemapper[timehighest.hr]}}** (*в среднем по
      **{{"%.2f"|format(highestiter.kr/num_hours)}}** NOK/kWh*).      

Sorry for the russian parts (if it is of any comfort, the original code was in finnish language) and especially for the ugly formatting, but Home Assistant keeps reformatting the code on saving the card, so there is no point in trying to make it look nice.

Organizing dashboards layout

Eventually you’ll probably want to change the dashboards layout, in particular almost immediately I wanted to have a 2-column layout with the second column being wider. And surprisingly, that isn’t wasn’t possible/trivial to do out of the box.

Until there was a way, one could use the layout-card frontend plugin, which can be installed via HACS. It probably offers more than what I am using it for, but for me the grid layout was enough, so now I can organize my cards like this:

Home Assistant, dashboard with grid layout

The code for this:

views:
  - title: Home
    cards:
      - ...
      - ...
    type: custom:grid-layout
    layout:
      grid-template-columns: 35% auto
      grid-template-rows: auto
      mediaquery:
        '(max-width: 800px)':
          grid-template-columns: 100%

So I survived with that for a bit (never figured out how to have more than one mediaquery), and then Home Assistant added sections, which brought very nice layouting capabilities:

Home Assistant, sections layout

Using sections, here’s how I could arrange the dashboard at full width with 3 columns:

Home Assistant, section with 3 columns

and here’s how it automatically rearranges in 2 columns on narrower screens:

Home Assistant, section with 2 columns

It also rearranges into 1 column on even narrower screens, but that would be a one kilometer long screenshot, so I won’t put it here.

So I switched to sections and deleted the layout-card plugin.

Backups

Having configured so many things, you should definitely think about backups.

I’ve come up with the following script:

$ mkdir /root/_backups
$ sudo nano /root/_backups/backup.sh
#!/bin/bash

dt="$(date +%Y.%m.%d-%H%M%S)"
backupname="vero-backup_$dt"
workingdir="/root/_backups/$backupname"
mkdir -p $workingdir

# Home Assistant and friends
tar -czf $workingdir/home-assistant.tar.gz \
    /root/homeassistant \
    /root/mosquitto \
    /root/zigbee2mqtt

# NGINX
tar -czf $workingdir/nginx.tar.gz \
    /etc/nginx/sites-available/* \
    /etc/nginx/mime.types \
    /etc/nginx/nginx.conf

# OSMC (given that it makes backups in this folder)
cp /home/osmc/backups/* $workingdir/

# cron
cp /var/spool/cron/crontabs/* $workingdir/

# the script itself
cp /root/_backups/backup.sh $workingdir/

and made it run on schedule:

$ sudo crontab -e
1 1 */20 * * /root/_backups/backup.sh
# or with Healthchecks reporting: https://github.com/retifrav/scraps/blob/master/_linux/installing-healthchecks/index.md
#1 1 */20 * * /root/_backups/backup.sh > /dev/null 2>&1 && /usr/bin/curl -X POST -fsS -m 10 --retry 5 -o /dev/null https://healthchecks.YOUR.DOMAIN/ping/ID/vero-backup

It would also be a good idea to upload/download the backups from the device to some reliable external storage.

Before making a backup, you might want to stop the containers, so files wouldn’t change during the backup procedure:

$ sudo docker stop $(sudo docker ps -aq)
$ sudo /root/_backups/backup.sh
$ sudo docker start $(sudo docker ps -aq)

Updates

In general, the procedure of updating something (that is run in a Docker container) is to get the latest Docker image and re-create the container. So with Home Assistant as an example in my particular network setup (note the --network and --ip arguments), the steps would be:

$ echo "Stop all the containers for making a backup"
$ sudo docker stop $(sudo docker ps -aq)
$ sudo /root/_backups/backup.sh

$ echo "Remove the existing Home Assistant container"
$ sudo docker rm homeassistant
$ echo "Get the latest image"
$ sudo docker pull ghcr.io/home-assistant/home-assistant:stable
$ echo "Re-create the container"
$ sudo docker run -d \
    --name homeassistant \
    --restart=unless-stopped \
    --network hub \
    --ip 172.18.0.100 \
    -e TZ=Europe/Amsterdam \
    -v /root/homeassistant:/config \
    ghcr.io/home-assistant/home-assistant:stable

$ echo "Start other containers back"
$ echo "Should be able to use this instead: `sudo docker start $(sudo docker ps -aq)`"
$ sudo docker start mosquitto
$ sudo docker start zigbee2mqtt

As you already know, thanks to Home Assistant data being stored in a mapped volume location, it is totally fine to destroy and re-create its container literally whenever, which makes updating Home Assistant a very simple procedure.

Performance monitoring with btop

As a bonus part, I wanted to install btop tool for watching the system CPU load.

There is no(?) pre-built package for btop on Debian, so I had to build it from sources, and for that it needs some tools and dependencies to be installed first, out of which CMake I also had to build from sources, because packaged version 3.18.4 is a bit old, and btop requires at least 3.24:

$ sudo apt install build-essential libssl-dev ninja-build

$ cd ~/downloads
$ wget https://github.com/Kitware/CMake/releases/download/v3.30.2/cmake-3.30.2.tar.gz
$ tar -xvf ./cmake-3.30.2.tar.gz
$ rm ./cmake-3.30.2.tar.gz
$ mv ./cmake-3.30.2 ~/programs/cmake

$ cd ~/programs/cmake
$ ./bootstrap
$ make -j$(nproc)
$ sudo make install

Then building btop itself:

$ cd ~/programs
$ git clone https://github.com/aristocratos/btop
$ cd ./btop
$ git checkout v1.3.2
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DBTOP_GPU=0 ..
$ cmake --build .
$ sudo cmake --install .

$ cd ~
$ btop --version
btop version: 1.3.2

To measure the CPU load, I kept it monitoring the system performance for several minutes while playing a 4K movie and running Docker containers with Home Assistant and its components:

Home Assistant on Vero V, CPU load in btop

And it barely broke any sweat.