<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Bernhart</title>
    <description>The latest articles on DEV Community by Michael Bernhart (@cloudapp_dev).</description>
    <link>https://dev.to/cloudapp_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3974608%2Fc22d5e7e-7665-4ecb-b10b-4d4bb29e2c04.png</url>
      <title>DEV Community: Michael Bernhart</title>
      <link>https://dev.to/cloudapp_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudapp_dev"/>
    <language>en</language>
    <item>
      <title>One Day of Home Assistant Logs: 775 Errors, Seven Findings</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 09 Oct 2026 10:17:15 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/one-day-of-home-assistant-logs-775-errors-seven-findings-4k6c</link>
      <guid>https://dev.to/cloudapp_dev/one-day-of-home-assistant-logs-775-errors-seven-findings-4k6c</guid>
      <description>&lt;p&gt;My production Home Assistant has been running as a Docker container on a small cloud VM since 2024, on version 2024.11.1, with about 570 entities, 73 automations and a KNX bus behind it. I had not read its log in months. In September I made myself do it properly: one full day of log, every error and warning counted and grouped before touching anything, and only then a repair list in order of priority. This post is that list. It includes two findings that needed no repair and one template that briefly reported the garage door as open.&lt;/p&gt;

&lt;p&gt;The numbers for the 24 hours from 12 September, 15:25, to 13 September, 15:25: 10,490 log lines, 775 ERROR entries, 5,077 WARNING entries. Several entries share a cause, so before reading any of them I grouped them by the logger name in square brackets, which is the fifth field in this log format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# one day of log, counted before reading anything&lt;/span&gt;
docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 24h homeassistant 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;          &lt;span class="c"&gt;# 10490&lt;/span&gt;
docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 24h homeassistant 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;' ERROR '&lt;/span&gt;   &lt;span class="c"&gt;# 775&lt;/span&gt;
docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 24h homeassistant 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;' WARNING '&lt;/span&gt; &lt;span class="c"&gt;# 5077&lt;/span&gt;
&lt;span class="c"&gt;# group by logger name (field 5), most frequent first&lt;/span&gt;
docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 24h homeassistant 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;' (ERROR|WARNING) '&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $5}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ground truth before reading a single error
&lt;/h2&gt;

&lt;p&gt;Before interpreting anything I checked the things that would make the log meaningless if they were off. Container running since 27 August, one historical restart, not OOM-killed. Host with 3.8 GiB RAM, 2.3 GiB free, the container at about 800 MiB. Swap 4 GiB with 1 MiB used. Disk 63 percent full. The recorder database at 60 MiB with a 4.3 MiB write-ahead log, and a read-only SQLite quick_check returning ok. A config check inside the container with no errors. The four configuration files on the VM matched the repository by SHA-256. I also ran a static check of all 73 automations against the entity registry, with one rule for reading the result: an entity that is not registered is not automatically missing.&lt;/p&gt;

&lt;p&gt;That took twenty minutes and ruled out the boring explanations. Memory and disk were sufficient, the database check passed, and the config on the machine matched the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the cameras caused 4,524 of the 5,077 warnings
&lt;/h2&gt;

&lt;p&gt;The cameras accounted for most of the warnings. 2,886 were warnings about slow updates from the two Tapo cameras. Another 1,638 came from two automations that set the motion detection parameters on those cameras. Their trigger was a time pattern on second 10, which fires once a minute, and they wrote the same settings every time. Both run in mode single. One call to the camera integration hung, so from that moment on every further run was refused with an already running warning, 1,440 times for one camera and 198 times for the other. The trace of the last accepted run of the first automation was from 6 September, so it had not run for a week and had logged a warning once a minute during that time.&lt;/p&gt;

&lt;p&gt;I repaired this the same afternoon in two steps. Reloading the config entries of the two cameras released the hung calls, without restarting Home Assistant or the cameras. In the automations, a setting is now only written when the entity does not already report the target value, which was 24 places in the file. Trigger and mode stayed as they were. Switching to mode parallel or silencing the warning would have left the hung call in place and removed the only sign of it. I did not add a timeout around the camera calls, so a call that hangs in the library can block these automations again, and the already running warning is how I would notice. The example below shows the condition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: fires every minute at second 10, mode single,&lt;/span&gt;
&lt;span class="c1"&gt;# and writes the same detection settings every time&lt;/span&gt;
&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time_pattern&lt;/span&gt;
    &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10"&lt;/span&gt;
&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;

&lt;span class="c1"&gt;# after: only write when the setting actually differs&lt;/span&gt;
&lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template&lt;/span&gt;
    &lt;span class="na"&gt;value_template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;{{ not is_state('switch.camera_motion_detection', 'on') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 2: a cloud integration that reported loaded and delivered nothing
&lt;/h2&gt;

&lt;p&gt;The FusionSolar integration polls Huawei's cloud kiosk for daily, monthly and yearly energy. 144 failed polls in 24 hours, one every ten minutes, each followed by three errors about missing energy fields, 576 log entries in total. All four of its sensors stood at unknown. The integration itself reported loaded, which is what the integrations page shows, so nothing looked wrong from the UI.&lt;/p&gt;

&lt;p&gt;The local Modbus sensors on the same inverter still reported a daily yield of 22.63 kWh at the same time, so I could read the yield locally while the cloud sensors stood at unknown. Before fixing the kiosk reference I checked whether anything used those four sensors: no automation, no dashboard, not the energy configuration. I disabled the integration instead of repairing it. Home Assistant answered with require_restart and failed_unload, which is its way of saying the disable only completes after a restart. What I take from this is that loaded only describes the integration's setup. Whether its data arrives has to be checked on the sensors. My values have come from the &lt;a href="https://www.cloudapp.dev/home-assistant-huawei-sun2000-modbus-registers" rel="noopener noreferrer"&gt;local Modbus registers&lt;/a&gt; for a year, and the cloud integration was still set up although nothing used its sensors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: 123 errors from a REST sensor that could not handle null
&lt;/h2&gt;

&lt;p&gt;The my-PV heating rod exposes its state over a local HTTP endpoint, read by the &lt;a href="https://www.cloudapp.dev/home-assistant-rest-sensor-cloud-api" rel="noopener noreferrer"&gt;REST sensor&lt;/a&gt; pattern. 123 update errors in the day, 110 of them on the battery charge sensor: the API returned null for a field, the template passed it through, and Home Assistant refused the string None as a value with unit W. Eight further timeouts on the same endpoint. The fix treats missing data as unavailable instead of raising an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rest.yaml, the sensor that failed 110 times in a day&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Battery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;charge"&lt;/span&gt;
  &lt;span class="na"&gt;unit_of_measurement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;W&lt;/span&gt;
  &lt;span class="c1"&gt;# the API returns null while the device reboots; "None" is not a number&lt;/span&gt;
  &lt;span class="na"&gt;value_template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;{{ value_json.m2sum | default(0, true) | float(0) }}&lt;/span&gt;
  &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;{{ value_json.m2sum is number }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail cost an extra round: my first version fell back to the string unknown, which produced the same conversion error, because Home Assistant 2024.11 writes the native value before it re-evaluates availability. The version above, a numeric fallback plus a separate availability template, went through rest.reload without a restart, and the sensor showed 0 W a minute later. On this core version a transient numeric fallback can still be written during the transition. I have only tested this on 2024.11.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: eleven KNX addresses that would never answer
&lt;/h2&gt;

&lt;p&gt;Roughly once an hour the KNX integration logged read timeouts for eleven group addresses, the operating mode state addresses of eleven thermostats. The errors predate everything else in the log, so this was not new. I looked the addresses up in the ETS project: they belong to a forced-mode object whose read flag is disabled. Home Assistant was sending a read request to an object that is configured never to answer. There is no readable mode feedback on these devices.&lt;/p&gt;

&lt;p&gt;I removed the eleven state addresses from the YAML instead of guessing replacements or disabling state synchronisation globally. The mode commands, temperature and setpoint feedback are untouched. Home Assistant now has no independent confirmation of which mode a thermostat is in. For that, a proper feedback object would have to be provided in ETS. It is the same problem I wrote about when the &lt;a href="https://www.cloudapp.dev/home-assistant-knx-thermostat-standby-after-restart-fix" rel="noopener noreferrer"&gt;thermostats dropped to standby after every restart&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: 100 unavailable entities, and why that number means little
&lt;/h2&gt;

&lt;p&gt;100 of 570 state objects were unavailable, 38 of them restored from the registry with no live source. Grouped: 44 Tapo entities from a camera that is deliberately switched off, 19 from the mobile app on phones, 13 TP-Link, 11 automations that are disabled leftovers, and a handful of templates and helpers. Two of those needed action: a smart plug has been in setup_in_progress since 28 August and is not reachable on its ports, and HACS has been asking for re-authentication since 8 September. The other 90 or so were expected.&lt;/p&gt;

&lt;p&gt;I mention this because bulk-deleting unavailable entities is a popular cleanup step, and here it would have removed entities that automations reference. I checked each group for use before removing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 6: the garage door that was reported open
&lt;/h2&gt;

&lt;p&gt;This one came a few days later, during the restart for the KNX changes, and it is the one I consider most serious. The raw door contact went unavailable for a moment while the KNX integration came up. A template sensor derived the door state from it with the expression not is_state(contact, 'on'). Unavailable is not on, so for that moment the template reported the garage door as open, and the dashboard showed it.&lt;/p&gt;

&lt;p&gt;I checked the recorder data from shortly before and after the restart. I found no switch command for the door, no knx.send to its address and no triggered automation. That is no proof that Home Assistant sent nothing, because I have not verified that all of this would have been recorded. Whether the door moved I can neither prove nor rule out, because the bus telegrams were not recorded. The template now has an availability condition and becomes unavailable together with its source instead of inventing a state. That change went live through a template reload, no restart needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: unavailable counted as "open"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;binary_sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Garage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;door&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;open"&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is_state('binary_sensor.garage_contact',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'on')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

&lt;span class="c1"&gt;# after: the template becomes unavailable together with its source&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;binary_sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Garage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;door&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;open"&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is_state('binary_sensor.garage_contact',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'on')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;{{ states('binary_sensor.garage_contact') not in ['unknown', 'unavailable'] }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 7: what the restart itself surfaced
&lt;/h2&gt;

&lt;p&gt;The restart on 16 September, done only after the changes above were prepared and checked, brought up two more. The old Shelly custom integration threw a TypeError on shutdown, made blocking calls in the event loop and left WebSocket errors behind. The boiler temperature sensor had two problems. Its template used float without a fallback for an unavailable source, and it published 701 where the precise sensor showed 70.1 degrees, a scaling error. Three disabled automations compare that sensor against 50 and 55 degrees, so it had to be fixed before any of them is enabled again.&lt;/p&gt;

&lt;p&gt;The boiler template now checks that its source is available and numeric, uses float(0) and has the scaling corrected by a factor of ten. After the reload the sensor showed 66.1 instead of 661 degrees. For the &lt;a href="https://www.cloudapp.dev/home-assistant-shelly-plus-1pm-2pm" rel="noopener noreferrer"&gt;Shelly integration&lt;/a&gt; I wrote a small compatibility module that moves the library import and shutdown into the executor, serialises the connection checks and closes the sockets with a bounded wait, plus four regression tests. It is a local patch on top of a custom component and has to be re-checked after every HACS update of that integration. The second restart came up with zero ERROR lines, the boiler at 66.2 degrees and the garage door closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repair order and the restart at the end
&lt;/h2&gt;

&lt;p&gt;The repair order was: unblock the camera automations, disable the dead cloud integration and fix the plug and the HACS login, fix the null handling, remove the KNX addresses that cannot answer, and only then clean up the disabled leftovers by actual use. Two things were off the table as diagnostic steps: a blanket restart, and a blanket deletion of unavailable entities. The restart came last, after the changes, and it was the step that found finding 6 and 7.&lt;/p&gt;

&lt;p&gt;Three of the seven findings affected operation: the hung camera calls, the null handling, and the template that turned unavailable into an open door. The KNX read timeouts and the errors from the restart did not affect operation, but they had causes I could remove. The cloud integration was unused, and most of the 100 unavailable entities were expected. The camera automation had been logging its warning since 6 September, and I only saw it when I read the log. My &lt;a href="https://www.cloudapp.dev/home-assistant-docker-update-rollback" rel="noopener noreferrer"&gt;update workflow&lt;/a&gt; has me read the log for a few minutes after each update. Between updates I had not been reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is 775 errors a day a lot for Home Assistant?
&lt;/h3&gt;

&lt;p&gt;Here it was mostly four causes repeating. I judge the number by the causes behind it and by what stopped working, and that was three things.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I restart Home Assistant when the log fills up?
&lt;/h3&gt;

&lt;p&gt;Not as a first step. I would save the log and the automation traces first and look at the repeating entries, because a restart changes the state I want to examine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a template report a state when its source is unavailable?
&lt;/h3&gt;

&lt;p&gt;Because not is_state(x, 'on') is also true for unknown and unavailable. An availability template that excludes those two source states fixes it.&lt;/p&gt;

&lt;p&gt;I wrote this for my own blog first, where the other posts about running this instance are, including the update workflow and the backup setup. I read the log on 13 September, and the last of the fixes went in on 16 September.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>devops</category>
      <category>debugging</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Custom Cards in Home Assistant: Mushroom and button-card</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 06 Oct 2026 06:19:41 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/custom-cards-in-home-assistant-mushroom-and-button-card-2ohk</link>
      <guid>https://dev.to/cloudapp_dev/custom-cards-in-home-assistant-mushroom-and-button-card-2ohk</guid>
      <description>&lt;p&gt;The stock cards in Home Assistant get you a working dashboard in an afternoon. I wrote that one up in &lt;a href="https://www.cloudapp.dev/home-assistant-dashboard-from-zero-five-steps" rel="noopener noreferrer"&gt;five build steps&lt;/a&gt; and left it there for a while. What they do not give you is control over typography, over how a number is drawn, or over a ring that shows a battery level. For that you need custom cards, and this post is about the four I ended up with, the theme that holds them together, and the four things that broke while building it.&lt;/p&gt;

&lt;p&gt;Everything here runs on a demo instance on a small Hetzner VM with simulated sensors, the same one the &lt;a href="https://www.cloudapp.dev/home-assistant-themes-six-compared" rel="noopener noreferrer"&gt;theme comparison&lt;/a&gt; was shot on. The full YAML of the finished dashboard is in the &lt;a href="https://www.cloudapp.dev/home-assistant-dashboard-templates-to-copy" rel="noopener noreferrer"&gt;templates post&lt;/a&gt;. This post explains the parts of it that are not obvious from reading the file.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feja1zbxr9t3z3x0qdmxr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feja1zbxr9t3z3x0qdmxr.jpg"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Four cards, loaded without HACS
&lt;/h2&gt;

&lt;p&gt;The four custom cards are &lt;strong&gt;card-mod&lt;/strong&gt; (CSS injection into any card), &lt;strong&gt;Mushroom&lt;/strong&gt; (the compact tile cards), &lt;strong&gt;mini-graph-card&lt;/strong&gt; (the line charts) and &lt;strong&gt;button-card&lt;/strong&gt; (a card that renders whatever JavaScript you hand it). On a normal installation you install them through &lt;a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant" rel="noopener noreferrer"&gt;HACS&lt;/a&gt; and HACS registers the resources for you. On the demo VM I wanted the setup reproducible from the repository alone, so the four bundles sit under www/cards and get loaded from configuration.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!include_dir_merge_named&lt;/span&gt; &lt;span class="s"&gt;themes&lt;/span&gt;
  &lt;span class="na"&gt;extra_module_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/local/cards/card-mod.js&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/local/cards/mushroom.js&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/local/cards/mini-graph-card-bundle.js&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/local/cards/button-card.js&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/local/ha-fonts.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fifth entry is the font loader. It exists because a theme cannot declare fonts, which the next section covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Themes cannot declare fonts
&lt;/h2&gt;

&lt;p&gt;The design direction for this dashboard was an instrument panel: labels small, uppercase and widely spaced in Archivo, values large and calm in IBM Plex Mono with tabular figures. Tabular figures matter more than they sound. With proportional digits a value like 2.449 W changes width every time a digit changes, and the whole tile twitches once a minute. A screenshot does not show that, but in daily use the twitching is distracting.&lt;/p&gt;

&lt;p&gt;A Home Assistant theme can only set CSS variables. It cannot declare an @font-face rule, so the fonts have to come from somewhere else. A tiny ES module loaded through extra_module_url appends the rules to the document head, and because @font-face is document-wide it also applies inside the shadow roots of the cards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// www/ha-fonts.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CSS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
@font-face {
  font-family: 'Archivo';
  src: url('/local/fonts/archivo-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/local/fonts/plexmono-500.woff2') format('woff2');
  font-weight: 500;
  font-display: swap;
}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The font files are served from /local/fonts on the same machine, 76 KB for both families. No request to fonts.googleapis.com. For a blog about running software yourself, loading the typeface from Google would be an odd exception, and it also means the dashboard renders identically without internet. One small surprise on the way: Google's font API returned the same variable font file for all four Archivo weights I requested. I downloaded four files and got the same 35 KB file four times, which I only noticed from the checksums.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mushroom template cards as value tiles
&lt;/h2&gt;

&lt;p&gt;The three tiles under the energy chart, consumption, feed-in and grid import, are Mushroom template cards. The template card takes a primary and a secondary text, both Jinja, an icon and an icon colour that can also be a template. That last part is what makes the tiles readable at a glance: grid import turns red only while there is any, feed-in turns teal only while there is any, otherwise both are grey.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot4hiljyxo8s201rx134.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot4hiljyxo8s201rx134.jpg"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom:mushroom-template-card&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Consumption&lt;/span&gt;
  &lt;span class="na"&gt;secondary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;states('sensor.demo_house_load')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;int&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;W"&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mdi:home-lightning-bolt&lt;/span&gt;
  &lt;span class="na"&gt;icon_color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grey&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vertical&lt;/span&gt;
  &lt;span class="na"&gt;card_mod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;valueblock&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;ha-card {&lt;/span&gt;
        &lt;span class="s"&gt;border: 1px solid var(--ha-card-border-color);&lt;/span&gt;
        &lt;span class="s"&gt;padding-bottom: 4px;&lt;/span&gt;
      &lt;span class="s"&gt;}&lt;/span&gt;
      &lt;span class="s"&gt;mushroom-state-info$: |&lt;/span&gt;
        &lt;span class="s"&gt;.primary {&lt;/span&gt;
          &lt;span class="s"&gt;font-family: Archivo, sans-serif !important;&lt;/span&gt;
          &lt;span class="s"&gt;font-size: 0.7rem !important;&lt;/span&gt;
          &lt;span class="s"&gt;letter-spacing: 0.14em;&lt;/span&gt;
          &lt;span class="s"&gt;text-transform: uppercase;&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;.secondary {&lt;/span&gt;
          &lt;span class="s"&gt;font-family: 'IBM Plex Mono', monospace !important;&lt;/span&gt;
          &lt;span class="s"&gt;font-size: 1.15rem !important;&lt;/span&gt;
          &lt;span class="s"&gt;font-variant-numeric: tabular-nums;&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom:mushroom-template-card&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Grid import&lt;/span&gt;
  &lt;span class="na"&gt;secondary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;states('sensor.demo_grid_import')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;int&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;W"&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mdi:transmission-tower-export&lt;/span&gt;
  &lt;span class="na"&gt;icon_color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;{{ 'red' if states('sensor.demo_grid_import') | int &amp;gt; 0 else 'grey' }}&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vertical&lt;/span&gt;
  &lt;span class="na"&gt;card_mod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*valueblock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card-mod block is what enforces the typography. The mushroom-state-info$ selector with the dollar sign reaches into the shadow root of the card, which is where the primary and secondary spans live. The YAML anchor at the top of the first card and the alias on the others keep the style in one place. Eleven template cards on this dashboard share two such anchors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The battery ring as a single SVG stroke
&lt;/h2&gt;

&lt;p&gt;The storage tile shows the state of charge as a ring. My first version was a button-card with a conic-gradient background and a smaller circle on top of it, whose background was set to var(--ha-card-background) to punch the hole. That produced a full disc. Inside the shadow DOM of button-card the variable does not resolve, so the inner circle was transparent and the gradient showed through.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wezjltdaw9wz4awf1ug.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wezjltdaw9wz4awf1ug.jpg"&gt;&lt;/a&gt;&lt;br&gt;
The version that works draws the ring as an SVG circle and uses stroke-dasharray to show the filled portion. The colour switches at 60 and 25 percent, and the whole thing is a custom field in button-card, computed in JavaScript from the sensor state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom:button-card&lt;/span&gt;
  &lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sensor.demo_battery_soc&lt;/span&gt;
  &lt;span class="na"&gt;show_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;show_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;show_icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;custom_fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;[[[&lt;/span&gt;
        &lt;span class="s"&gt;const soc = Math.max(0, Math.min(100,&lt;/span&gt;
          &lt;span class="s"&gt;Number(states['sensor.demo_battery_soc'].state) || 0));&lt;/span&gt;
        &lt;span class="s"&gt;const col = soc &amp;gt;= 60 ? '#54cabe' : (soc &amp;gt;= 25 ? '#f0a44a' : '#e0685f');&lt;/span&gt;
        &lt;span class="s"&gt;const R = 52, C = 2 * Math.PI * R;&lt;/span&gt;
        &lt;span class="s"&gt;const dash = (soc / 100) * C;&lt;/span&gt;
        &lt;span class="s"&gt;return `&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;svg class="ring" viewBox="0 0 120 120"&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;circle class="track" cx="60" cy="60" r="${R}"/&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;circle class="val" cx="60" cy="60" r="${R}"&lt;/span&gt;
                    &lt;span class="s"&gt;stroke="${col}"&lt;/span&gt;
                    &lt;span class="s"&gt;stroke-dasharray="${dash.toFixed(1)} ${(C - dash).toFixed(1)}"&lt;/span&gt;
                    &lt;span class="s"&gt;transform="rotate(-90 60 60)"/&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;text class="num" x="60" y="58"&amp;gt;${soc}&amp;lt;/text&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;text class="lbl" x="60" y="76"&amp;gt;STATE OF CHARGE&amp;lt;/text&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;/svg&amp;gt;`;&lt;/span&gt;
      &lt;span class="s"&gt;]]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two more things that broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Markdown cards as section headings destroyed the grid.&lt;/strong&gt; I had used markdown cards for the small uppercase section labels. A markdown card counts as a grid cell, so the tiles below it wrapped onto the wrong columns. The native heading card, available since the sections layout, spans the full section width and does not take part in the grid. Every section label on the dashboard is a heading card with card-mod for the letter spacing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slider stayed neon yellow.&lt;/strong&gt; The target temperature control at the bottom right is a Mushroom number card. I had set Mushroom's own colour variable in the theme, and the slider ignored it. Mushroom colours that slider through Home Assistant's own --rgb-state-number variable, which I found by reading the computed styles in the running frontend. Both variables now sit in the theme:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbq1rpnyekgseej36zdsy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbq1rpnyekgseej36zdsy.jpg"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# themes/instrument.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;instrument&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;modes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mush-rgb-state-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;240,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;164,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;74"&lt;/span&gt;
      &lt;span class="c1"&gt;# HA colours the input_number slider via --rgb-state-number,&lt;/span&gt;
      &lt;span class="c1"&gt;# Mushroom's own variable is not enough.&lt;/span&gt;
      &lt;span class="na"&gt;rgb-state-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;240,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;164,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;74"&lt;/span&gt;
    &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mush-rgb-state-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;180,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;106,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;18"&lt;/span&gt;
      &lt;span class="na"&gt;rgb-state-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;180,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;106,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;18"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth one concerns testing. Light mode kept showing Home Assistant's default white instead of my theme. The frontend.set_theme service only sets the server default per mode, and as long as a user profile says backend-selected it is not reliably controllable which mode applies. For reproducible screenshots the theme has to be set explicitly, which the screenshot script does by writing selectedTheme into localStorage before it loads the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would not do again
&lt;/h2&gt;

&lt;p&gt;The instrument dashboard uses four custom cards and about 300 lines of YAML for what the stock version does in 23 cards and no plugins. The difference is visible in the two screenshots in the templates post. It comes with a maintenance cost. Each of the four cards is a separate project with its own release cycle, and card-mod in particular reaches into shadow DOM selectors that the Mushroom maintainers can rename at any time. I expect the value tiles to survive a Mushroom update. I am less sure about the card-mod typography.&lt;/p&gt;

&lt;p&gt;If I were starting a dashboard for daily use rather than for a blog series, I would take Mushroom alone and skip card-mod, accept the default typography, and spend the time on the sensors instead. The ring I would keep. It is one card, it depends on nothing but button-card, and it shows a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need HACS to use Mushroom or button-card?
&lt;/h3&gt;

&lt;p&gt;No. HACS downloads the bundle and registers the resource for you, which is convenient, but the cards are plain JavaScript modules. Put the file under www and list it under extra_module_url, or add it as a dashboard resource in the UI. The demo VM loads all four this way so the setup lives entirely in the repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does card-mod need the dollar sign in the selector?
&lt;/h3&gt;

&lt;p&gt;The dollar sign tells card-mod to enter the shadow root of the element named before it. Mushroom renders its texts inside mushroom-state-info, whose internals are not reachable from the outside with a normal CSS selector. Without the dollar sign the rule applies to the host element and does nothing visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the SVG ring update live?
&lt;/h3&gt;

&lt;p&gt;Yes. button-card re-evaluates the JavaScript in a custom field whenever the entity it is bound to changes state. On the demo VM the state of charge sensor updates every few seconds, and the ring follows without a page reload.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>dashboard</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Where Home Assistant Runs: One Small VM, Measured</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 02 Oct 2026 08:22:10 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/where-home-assistant-runs-one-small-vm-measured-28bi</link>
      <guid>https://dev.to/cloudapp_dev/where-home-assistant-runs-one-small-vm-measured-28bi</guid>
      <description>&lt;p&gt;I moved Home Assistant onto a machine of its own this month — a small cloud VM, nothing sitting at home. Before I did, I wanted a straight answer to something the documentation does not give you: what does it actually need?&lt;/p&gt;

&lt;p&gt;What I settled on is a Hetzner cx23. Two vCPU, 4 GB of memory, 40 GB of disk, in Nuremberg, on Ubuntu 24.04. That comes to 7.09 euro a month before tax. Home Assistant 2026.8.2 runs in a container on Docker 29.7.2, with &lt;strong&gt;network_mode: host&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pr9dbdoicndtvtoqwp5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pr9dbdoicndtvtoqwp5.jpg" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;What the machine serves: a dashboard built from stock cards, running on demo sensors.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually uses
&lt;/h2&gt;

&lt;p&gt;With the container stopped, the machine sits at 611 MB. That is Ubuntu plus the Docker daemon and nothing else. Two minutes after starting Home Assistant it reads 960 MB, and the container accounts for 339 MB of that.&lt;/p&gt;

&lt;p&gt;The figure moves, which is why the timing belongs next to it. Immediately after start it reads 850 MB. After 22 hours of uptime I measured between 969 and 985 MB. Anyone quoting a memory number for Home Assistant should say when they took it; I got all three of these on the same machine.&lt;/p&gt;

&lt;p&gt;From &lt;strong&gt;docker compose up&lt;/strong&gt; to an HTTP 200 on port 8123 took 6.6 seconds, measured in half-second steps. The interface answers before the integrations have finished initialising, so the 6.6 seconds measure the web server coming up. The integrations take longer.&lt;/p&gt;

&lt;p&gt;The image is 625 MB. After the base setup the disk sat at 5.4 GB of 38 available, and at 9.4 GB once I added a 4 GB swap file. The jump is the swap file.&lt;/p&gt;

&lt;p&gt;Two vCPU and 4 GB is more than a container install with no add-ons needs today. I would not go lower, because the recorder database grows steadily and swap on a 4 GB machine is what keeps an upgrade from being killed for memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not running on the Kubernetes cluster
&lt;/h2&gt;

&lt;p&gt;I already run a single-node k3s for other things, so the obvious question was whether Home Assistant belonged there. I measured that machine first: 11.7 percent CPU as a baseline across seven days, before any workload of mine runs at all, and 1 to 1.5 GB of memory for the control plane sitting idle.&lt;/p&gt;

&lt;p&gt;What decided it was networking. Home Assistant needs mDNS and broadcast traffic to find devices on the network. In Docker that is &lt;strong&gt;network_mode: host&lt;/strong&gt;, one line. The Kubernetes equivalent is &lt;strong&gt;hostNetwork: true&lt;/strong&gt;, and that gives up the network isolation which was the reason to run Kubernetes in the first place.&lt;/p&gt;

&lt;p&gt;One correction while I am here, because it comes up often: there is no Docker in K3s. K3s runs containerd, and the Docker backend has been deprecated for a while. The image is the same OCI image; only the runtime underneath differs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a standard setup leaves open
&lt;/h2&gt;

&lt;p&gt;I went over the machine the day after cloud-init built it, and found four things that were worth fixing. None of them shows up as an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ufw was installed and never enabled.&lt;/strong&gt; The package was in the cloud-init list, and without an explicit enable the result is a firewall reporting &lt;strong&gt;inactive&lt;/strong&gt;. It does not matter while the provider firewall in front of it filters everything, and it starts mattering the moment ports 80 and 443 open for TLS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;sshd had password authentication on&lt;/strong&gt;, which is the Ubuntu default. Root itself is key-only, so nothing was exposed yet — but any additional user created later could have logged in with a password.&lt;/p&gt;

&lt;p&gt;There was &lt;strong&gt;no swap&lt;/strong&gt; on a 4 GB machine, and the SSH allow-list held exactly one address: a home connection whose IP rotates. I have locked myself out that way on another project, so a fixed second address went in as an anchor.&lt;/p&gt;

&lt;p&gt;All four are fixed on the running machine and written back into the cloud-init file, so rebuilding does not quietly undo them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a machine in a data centre cannot do
&lt;/h2&gt;

&lt;p&gt;This is the part most posts about hosting Home Assistant leave out, and it is the first thing you should check before copying my setup. Your devices are on your home network. A VM in Nuremberg is not, and mDNS discovery stops at your router. Anything that speaks only on the local network — Zigbee sticks, KNX, most cheap WiFi plugs — is unreachable from there.&lt;/p&gt;

&lt;p&gt;For this build that is on purpose. It binds no device at all in its first phase; it exists to be set up cleanly and photographed. The house is still run by the old instance, which sits where it can see the hardware.&lt;/p&gt;

&lt;p&gt;If you want a cloud machine and local devices, you need a tunnel back home, and then the tunnel becomes the part most likely to fail, rather than the server. A machine at home avoids that entirely. What it costs you instead is the uptime, the backups and the upgrade discipline that the provider otherwise handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;The container install, because nothing about it has needed attention. No Supervisor, no add-ons, one compose file, and an upgrade is a changed image tag. What I gave up is the add-on ecosystem, and for a machine that does nothing but run Home Assistant I have not missed it yet. Worth separating two things that get confused here: add-ons are a Supervisor feature and a container install has none, while &lt;a href="https://www.cloudapp.dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user" rel="noopener noreferrer"&gt;HACS&lt;/a&gt; works fine in a container and gives you integrations, cards and themes.&lt;/p&gt;

&lt;p&gt;If you want the install itself rather than the machine choice, I wrote that up separately for &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;Docker on a Linux VM&lt;/a&gt;. And once it runs, the first thing worth doing is &lt;a href="https://www.cloudapp.dev/home-assistant-backup-strategy-full-host" rel="noopener noreferrer"&gt;a backup that covers the whole host&lt;/a&gt;, not just the config folder.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>21 Releases Behind: I Rebuilt Instead of Upgrading</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Thu, 01 Oct 2026 18:29:14 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/21-releases-behind-i-rebuilt-instead-of-upgrading-3lnl</link>
      <guid>https://dev.to/cloudapp_dev/21-releases-behind-i-rebuilt-instead-of-upgrading-3lnl</guid>
      <description>&lt;p&gt;The Home Assistant that runs my house is on 2024.11.1, released in November 2024. The current version is 2026.8.2. That is roughly 21 monthly releases of distance, and it has been running the whole time without complaining.&lt;/p&gt;

&lt;p&gt;Before deciding anything I checked what was actually in there, against the running instance's own REST API rather than from memory: &lt;strong&gt;751 entities, 150 components, no themes at all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  I did not try to upgrade
&lt;/h2&gt;

&lt;p&gt;This matters more than the rest of the post, so it goes first. There was no failed upgrade. Nothing broke, because nothing was attempted: I decided against upgrading before trying it.&lt;/p&gt;

&lt;p&gt;The reasons were unglamorous and they accumulated. The old instance runs from a plain &lt;strong&gt;docker run&lt;/strong&gt; with no compose file, so there is no single document describing how it starts. Its configuration has grown for two years and nobody has read all of it recently. And a rollback would have meant restoring a VM snapshot, which takes the whole machine back, including anything else on it. That is the failure I would fix first on any instance meant to last: &lt;a href="https://www.cloudapp.dev/splitting-home-assistant-automations-yaml" rel="noopener noreferrer"&gt;splitting the automations into separate YAML files&lt;/a&gt; is what keeps a configuration readable long enough to upgrade it.&lt;/p&gt;

&lt;p&gt;None of that is impossible. Together it was enough that a clean build on a new machine looked like less work than an upgrade whose failure mode I could not describe in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen that shows this was a choice
&lt;/h2&gt;

&lt;p&gt;When you start a fresh Home Assistant, the very first screen offers two things side by side: create a new home, or restore from a backup. The restore option gets the same weight as the other one.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3o1y8fefo4g3arxi1ij.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3o1y8fefo4g3arxi1ij.jpg" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The first screen. Restoring from a backup sits next to creating a new home.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I mention it because it weakens my own case. The way back was directly available, and I did not take it. I preferred a clean start, and nothing needed rescuing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a fresh install starts with
&lt;/h2&gt;

&lt;p&gt;Three things surprised me on the way through, and each takes one sentence in a guide, so guides tend to skip them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pre-selected country is the United States.&lt;/strong&gt; That screen has exactly one field and it starts wrong for most of the people reading this. Click through without looking and you get imperial units and dollars.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftno5cngfyldt3phrx9xv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftno5cngfyldt3phrx9xv.jpg" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One field, pre-set to the United States.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The language selector is on the welcome screen, before you create the user.&lt;/strong&gt; Switching it there saves you from having field names and area names created in English and then living with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Home Assistant creates three areas by itself&lt;/strong&gt; — living room, kitchen, bedroom — and puts them on the first dashboard next to an empty summaries column. That is where a fresh install actually starts.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ihooluuuf2yclna80e2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ihooluuuf2yclna80e2.jpg" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The first dashboard: three invented areas and an empty summaries column.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What rebuilding costs
&lt;/h2&gt;

&lt;p&gt;The part I underestimated is that a rebuild means dealing with all 751 entities again, one way or another. Every entity in the old instance exists because an integration was added and configured at some point, and none of that comes along. The new machine starts with the three areas Home Assistant invents and nothing else.&lt;/p&gt;

&lt;p&gt;The history goes too. Two years of recorder data stays with the old instance, so every long-run statistic starts from zero on the new one — energy totals, temperature trends, anything with a graph behind it. A backup restore would have kept all of it, which is exactly the trade being made here.&lt;/p&gt;

&lt;p&gt;The third cost is the tuning. Automations that were adjusted over two years because they fired at the wrong moment carry no record of why they look the way they do. Copying them across is easy. Working out which of the odd-looking conditions were deliberate takes much longer.&lt;/p&gt;

&lt;p&gt;So the honest summary is that I chose a clean machine over two years of accumulated state, on a house that is not urgently broken. If the old instance were failing, I would have restored the backup and dealt with the mess afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old one is still running
&lt;/h2&gt;

&lt;p&gt;The new build binds no device in its first phase. The old instance continues to run the house, untouched, which is the only reason this was a comfortable decision at all. There is no cutover date yet and I am not setting one until the new machine has done something useful for a few weeks.&lt;/p&gt;

&lt;p&gt;If you are in the same position, the thing I would check first is whether your current instance can be described. If one file describes how it starts, an upgrade is usually an afternoon's work. If there is no such file, I would fix that first, and rebuilding is one way to do it.&lt;/p&gt;

&lt;p&gt;What made this safe was having a restore path I trusted. I wrote up &lt;a href="https://www.cloudapp.dev/home-assistant-backup-strategy-full-host" rel="noopener noreferrer"&gt;what a backup has to cover&lt;/a&gt; separately, and it is worth reading before you touch a two-year-old instance.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Splitting a 2,000-Line automations.yaml into 8 Files</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 29 Sep 2026 10:08:22 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/splitting-a-2000-line-automationsyaml-into-8-files-24cn</link>
      <guid>https://dev.to/cloudapp_dev/splitting-a-2000-line-automationsyaml-into-8-files-24cn</guid>
      <description>&lt;p&gt;My automations.yaml had quietly grown into a 60 KB monster. 2,144 lines. 63 automations stacked end to end in a single file, averaging about 34 lines each. Scrolling it was a chore, editing it made me nervous, and finding one particular heating automation meant a full-text search. The file worked, so there was never any pressure to touch it, and there was a good reason not to: when you refactor a live config you can silently drop the automation that keeps the heating from freezing or the alarm from missing a sensor.&lt;/p&gt;

&lt;p&gt;So this is a small story about a refactor I almost didn't do, and the one thing that made me trust it: I wrote a 40-line script to split the file, and then I wrote a second, longer script whose only job was to prove the first one hadn't lost anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one file stops scaling
&lt;/h2&gt;

&lt;p&gt;Home Assistant wires automations in through one line in &lt;strong&gt;configuration.yaml&lt;/strong&gt;: &lt;strong&gt;automation: !include automations.yaml&lt;/strong&gt;. That single include is convenient while the file is small. With 63 automations in there, a few groups had formed without my planning them. Heating was by far the biggest concern — 21 automations, a third of the whole file, driving 11 thermostats with day and night modes and room-by-room overrides. Alarm was the next slab at 13. Then a cluster around the AC·THOR power diverter, a handful of camera and detection rules, time-based switching, system housekeeping, and a long tail of one-offs.&lt;/p&gt;

&lt;p&gt;None of that structure was visible. It was just 2,144 lines in the order I'd happened to add each one. I wanted the heating rules in a heating file and the ability to open exactly the slice I was working on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 40-line splitter that classifies by name
&lt;/h2&gt;

&lt;p&gt;The splitter is deliberately boring. It's about 40 lines of pure standard-library Python — just &lt;strong&gt;yaml&lt;/strong&gt;, &lt;strong&gt;os&lt;/strong&gt;, and a &lt;strong&gt;defaultdict&lt;/strong&gt;. It loads the YAML list, walks each automation, decides which bucket it belongs to, and dumps one file per bucket into an &lt;strong&gt;automations/&lt;/strong&gt; directory.&lt;/p&gt;

&lt;p&gt;Here's the honest quirk: the classification isn't driven by any tag or metadata field, because there isn't one. It's substring matching on the automation's German &lt;strong&gt;alias&lt;/strong&gt;. If the alias contains "Heizung" it's heating; "Alarm" goes to alarm; "AC Thor" (in any of its hyphenated spellings) to ac_thor; camera and detection words to cameras; push and notification words to notifications; the time-control vocabulary to time_control; sync and reachability words to system; and everything the rules don't catch falls through to a misc bucket. So the classification depends entirely on how consistently I named things.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;automation&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;automations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alias&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Heizung&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;heating&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Alarm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alarm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AC Thor&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AC-Thor&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ac_thor&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... cameras / notifications / time_control / system / misc
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;misc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autos&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;automations/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_flow_style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="n"&gt;allow_unicode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of the dump arguments are worth explaining. &lt;strong&gt;allow_unicode=True&lt;/strong&gt; keeps the German umlauts intact instead of mangling them into escape sequences, so a name like "Büro" survives the round trip. And &lt;strong&gt;sort_keys=False&lt;/strong&gt; preserves the original key order inside each automation — the trigger, condition and action stay in the order I wrote them, instead of getting alphabetised into nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight buckets the data chose for itself
&lt;/h2&gt;

&lt;p&gt;I didn't pick eight up front; I had half expected to hand-carve a dozen or so files. The committed splitter emits eight category files: heating, alarm, ac_thor, cameras, notifications, time_control, system, and misc. The distribution is lopsided: heating is 21 automations (33%), alarm is 13 (21%), AC·THOR is 5 (8%), and then it thins fast into pairs and singletons. By estimated size the heating file lands around 700 lines on its own, alarm around 440, cameras around 270, ac_thor around 170.&lt;/p&gt;

&lt;p&gt;A 700-line heating file controlling 11 thermostats with near-identical day/night/presence logic is an obvious candidate for templating into a handful of parameterised scripts, and with it isolated I can see what that would look like. The AC·THOR file, by contrast, came out clean and self-contained: temperature monitoring, power management, and a bit of system detection, all in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validator
&lt;/h2&gt;

&lt;p&gt;Before I changed a single line of &lt;strong&gt;configuration.yaml&lt;/strong&gt;, I wrote a second script — about 160 lines — whose entire purpose is to re-read the original file and every split file and assert the round trip is lossless. It runs five checks. One: the total automation count matches. Two: the set of unique aliases matches, with no automation missing and none invented. Three: any duplicate aliases are identical between original and split. Four: every automation &lt;strong&gt;id&lt;/strong&gt; value matches as a set. Five: a per-alias deep dictionary comparison to catch silent content drift.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;original_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;split_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COUNT CHECK: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;split_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; automations (matches original)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;missing_in_split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original_aliases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_aliases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;extra_in_split&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_aliases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original_aliases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# IDs compared as sets; per-alias deep compare flags content drift
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;original_alias_set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;split_by_alias&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;original_by_alias&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;split_by_alias&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;content_mismatches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script separates errors from warnings. A count mismatch, a missing alias, an extra alias, an id-set divergence — those are hard errors, and the script exits 1 and refuses to bless the new layout. But the per-automation deep comparison is treated as a warning, and it exits 0 anyway. I did that on purpose: when you re-serialize YAML through the library, the bytes reformat — quoting style shifts, some flow collapses to block — but the parsed structure is functionally identical. If I treated every reformat as a failure, the validator would scream on every clean split and I'd learn to ignore it. In practice a lost automation aborts the run, and a change in quoting style only produces a warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching over
&lt;/h2&gt;

&lt;p&gt;With the validator passing, the switch is one line in &lt;strong&gt;configuration.yaml&lt;/strong&gt;: &lt;strong&gt;!include automations.yaml&lt;/strong&gt; becomes &lt;strong&gt;!include_dir_merge_list automations/&lt;/strong&gt;, and Home Assistant now merges every file in that directory into one automation list, exactly as if they were still concatenated.&lt;/p&gt;

&lt;p&gt;Then you reload without a full restart: a &lt;strong&gt;ha core check&lt;/strong&gt; to validate the config first, then a call to the &lt;strong&gt;homeassistant.reload&lt;/strong&gt; service against the automation entity — for me that's a small POST to the REST API at something like &lt;a href="http://homeassistant.local:8123" rel="noopener noreferrer"&gt;http://homeassistant.local:8123&lt;/a&gt;, no reboot, no downtime. The automations come back exactly as they were, just spread across eight readable files instead of one 2,144-line wall.&lt;/p&gt;

&lt;p&gt;If you take one habit from this, make it a naming convention: prefix new aliases with their category, like "Heating:  night mode", so the substring classifier stays deterministic as the config grows. Otherwise a new automation with an unusual name ends up in misc. The rest of this setup, &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;from the Docker-on-Azure install&lt;/a&gt; on up, was built in similar small steps. The next one is templating the 700-line heating file.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>automation</category>
      <category>yaml</category>
      <category>smarthome</category>
    </item>
    <item>
      <title>KNX Thermostat Preset Modes in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 23 Sep 2026 17:31:36 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/knx-thermostat-preset-modes-in-home-assistant-3co8</link>
      <guid>https://dev.to/cloudapp_dev/knx-thermostat-preset-modes-in-home-assistant-3co8</guid>
      <description>&lt;p&gt;&lt;em&gt;Crossposted from my blog, where the rest of the KNX and Home Assistant series lives.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I lost an evening to this. I dragged the living-room thermostat in Home Assistant up to 24 degrees, watched the climate card show 24 for about a second, and then watched it quietly fall back to 23. Set it again. Same thing. The card behaves as if I never touched it. My first instinct was the usual one: something is broken in the KNX integration, or the bus is dropping the write.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The thermostat was doing exactly what KNX thermostats are designed to do, and Home Assistant's climate card has no good way to tell you that. Once I understood the five preset modes underneath it, the &lt;strong&gt;manual control doesn't work&lt;/strong&gt; complaint turned out to be a design decision I had to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five modes hiding under one card
&lt;/h2&gt;

&lt;p&gt;Every KNX thermostat in the house speaks the standard HVAC operating-mode object (DPT 20.102). That object has exactly five states, and Home Assistant surfaces them as preset modes on the climate entity: &lt;strong&gt;Standby&lt;/strong&gt;, &lt;strong&gt;Building Protection&lt;/strong&gt; (frost protection), &lt;strong&gt;Economy&lt;/strong&gt; (the night setback), &lt;strong&gt;Comfort&lt;/strong&gt; (day), and &lt;strong&gt;Auto&lt;/strong&gt;. That dropdown of presets on the card is a direct view of a number the physical thermostat tracks on its own.&lt;/p&gt;

&lt;p&gt;The detail that cost me the evening: of those five, only &lt;strong&gt;Auto&lt;/strong&gt; lets you freely set a target temperature from Home Assistant. Comfort and Economy use a setpoint the device already holds. In my setup Comfort sits at around 23 degrees and Economy a couple of degrees lower, somewhere in the 19 to 20 range. Building Protection holds the room at roughly 7 degrees so a radiator never freezes. Those numbers live in the thermostat's own memory, not in Home Assistant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the temperature snaps back
&lt;/h2&gt;

&lt;p&gt;This is the sequence when you fight a Comfort-mode thermostat from the card. The thermostat is in Comfort, holding its device-fixed 23. You write 24. The card optimistically shows 24, because the write went out on the bus. A heartbeat later the thermostat re-publishes its stored Comfort setpoint on the read-back address, Home Assistant reads that value, and the card reverts to 23. The write did not get lost on the bus. The device overruled it, because it considers its stored setpoint authoritative while it is in Comfort.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Why a manual change 'snaps back' in Comfort/Economy:
# 1. preset = Comfort (device-fixed ~23C)
# 2. HA writes 24C -&amp;gt; card briefly shows 24C
# 3. thermostat re-publishes its stored comfort setpoint
#    on the target-temperature state (read-back) address
# 4. HA reads it back -&amp;gt; card reverts to 23C
# Fix: switch the preset to AUTO, THEN set any temp 18-25C.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is simple: switch the preset to Auto first, then set whatever temperature you want. In Auto the setpoint is yours to write and it sticks. Comfort and Economy are read-only by design: Home Assistant can read what they hold and cannot overwrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the five address roles actually do
&lt;/h2&gt;

&lt;p&gt;This makes a lot more sense once you've wired one of these entities up. Each KNX climate entity in my &lt;strong&gt;knx.yaml&lt;/strong&gt; is configured with five group-address roles, and they map cleanly onto the behaviour above: current temperature (read-only), target temperature (the setpoint write), target temperature state (the read-back the device uses to overrule you), operating mode (the write that changes the preset), and operating mode state (the read-back of which mode the device is actually in). Min and max temperature clamp at 18.0 and 25.0, and the step is 0.5, so the card moves in half-degree increments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;climate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Thermostat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Office"&lt;/span&gt;
    &lt;span class="na"&gt;temperature_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/0/1"&lt;/span&gt;             &lt;span class="c1"&gt;# current temp (read)&lt;/span&gt;
    &lt;span class="na"&gt;target_temperature_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/1/1"&lt;/span&gt;      &lt;span class="c1"&gt;# comfort setpoint (write)&lt;/span&gt;
    &lt;span class="na"&gt;target_temperature_state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/2/1"&lt;/span&gt; &lt;span class="c1"&gt;# setpoint read-back&lt;/span&gt;
    &lt;span class="na"&gt;operation_mode_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/3/1"&lt;/span&gt;          &lt;span class="c1"&gt;# HVAC mode (write)&lt;/span&gt;
    &lt;span class="na"&gt;operation_mode_state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/4/1"&lt;/span&gt;    &lt;span class="c1"&gt;# HVAC mode (read)&lt;/span&gt;
    &lt;span class="na"&gt;temperature_step&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;
    &lt;span class="na"&gt;min_temp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18.0&lt;/span&gt;
    &lt;span class="na"&gt;max_temp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One wrinkle worth knowing: on a couple of my thermostats the mode write and the mode state are separate group addresses, but on one of them I deliberately point both at the same address because that particular device just echoes its own mode back on the same object. Either works, as long as you match how the specific device behaves on the bus. I've abstracted the address above to a single illustrative example on purpose; the real per-room map is not something you want to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the automations actually use the modes
&lt;/h2&gt;

&lt;p&gt;Once the modes are clear, the automations follow directly. I have roughly a dozen KNX climate zones across the house on a single KNX/IP gateway, and the day-to-day comfort comes from three patterns that all just push a preset.&lt;/p&gt;

&lt;p&gt;The first is the daily schedule. A &lt;strong&gt;day mode&lt;/strong&gt; automation runs at 06:00 and sets every thermostat to Comfort. An &lt;strong&gt;evening&lt;/strong&gt; automation runs at 21:00 and drops them all to Economy for the night setback. I also wired the night setback to an &lt;strong&gt;input_boolean&lt;/strong&gt; on the dashboard so I can flip the whole house into night mode by hand without waiting for 21:00 — handy when everyone's gone to bed early.&lt;/p&gt;

&lt;p&gt;The second is the window logic, and it's my favourite use of Building Protection. When a window opens, an automation waits 5 seconds and then switches that room's thermostat to Building Protection, so the radiator stops dumping heat into an open room. When the window closes, after the same 5-second delay, it goes back to Comfort. There are more than a dozen of these, one per window-and-room pair. They never touch a temperature, only the preset, and that is why they are reliable.&lt;/p&gt;

&lt;p&gt;The third is the boring one that saved me a cold morning. After a Home Assistant or bus restart, some thermostats come back up defaulting to Standby because they briefly lost their connection. Standby means the room silently stops heating, and you don't notice until it's cold. So a startup-initialise automation re-applies the correct presets on boot, so a reboot at 02:00 doesn't turn into a cold living room at 07:00. If you run KNX climate through Home Assistant, write this automation before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;This is the part the climate card cannot tell you. The &lt;strong&gt;manual control doesn't work&lt;/strong&gt; feeling comes from the KNX HVAC mode model showing through a generic climate card that was designed for thermostats with one free setpoint. Comfort and Economy are device-fixed by design; Auto is the free-setpoint mode. The card shows you all of it at once and trusts you to know which mode you're in.&lt;/p&gt;

&lt;p&gt;That leaves a real choice, and I go back and forth on it. You can keep the schedule-plus-window setup, which gives you automatic night setback and open-window frost protection essentially for free, but you lose the ability to just grab a thermostat and override it, because Comfort will snap your change back. Or you run every zone in Auto all the time, which gives you full manual control from the card, but then you lose the automatic setback and the window-driven frost protection, because nothing is flipping presets for you anymore. The automatic behaviour and the manual override compete for the same setpoint, so there is no clean way to have both.&lt;/p&gt;

&lt;p&gt;I keep the automated version, because the house heats itself sensibly 360 days a year and I only fight it on the handful of evenings I want it warmer than Comfort. On those nights I now just flip the room to Auto first, a one-second habit. If you're wiring KNX climate into Home Assistant, this is the part of the docs I wish I had read first: which preset owns the setpoint. The rest of how this house runs on Home Assistant — &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;from the Docker-on-Azure install&lt;/a&gt; on up — is built from details like this one.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
    <item>
      <title>An Alarm Dashboard + Tecnoalarm Keypad in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Sat, 12 Sep 2026 18:11:49 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/an-alarm-dashboard-tecnoalarm-keypad-in-home-assistant-4e49</link>
      <guid>https://dev.to/cloudapp_dev/an-alarm-dashboard-tecnoalarm-keypad-in-home-assistant-4e49</guid>
      <description>&lt;p&gt;&lt;em&gt;Crossposted from my blog, where the rest of the Home Assistant series lives.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most interesting decision in this project was what &lt;strong&gt;not&lt;/strong&gt; to put on screen. An alarm dashboard is, by definition, a map of where my house is soft — every open window, every door someone could walk through. A careless version of it would also document how to get in. So before I lined up a single card, I made a rule for myself: the panel can be as detailed as I like on my own network, but anything I write about it gets renamed, abstracted, and stripped of the real layout. That tension between a useful operator console and a literal threat map shaped every choice below.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built it against fake sensors
&lt;/h2&gt;

&lt;p&gt;When I started, most of the physical door and window contacts weren't wired yet. Rather than wait, or climb a ladder for every iteration, I created &lt;strong&gt;simulated binary_sensors&lt;/strong&gt;, entity ids ending in &lt;strong&gt;_sensor_sim&lt;/strong&gt;, and built the entire dashboard against those. I could sit on the couch, flip a fake window "open", and watch the whole pipeline light up: status grid, logbook entry, notification, the lot.&lt;/p&gt;

&lt;p&gt;In hindsight it was the best decision of the build. UI work on a security panel is fiddly: you want to see the red state, the alarm-armed state, the everything-closed state — and you cannot summon those reliably from real hardware on demand. Simulated sensors gave me a controllable test bench. By the time the real contacts went in, the dashboard already knew how to render every state, and swapping a sim entity for a real one was a one-line change. If you're building any kind of state-driven HA UI, fake the inputs first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two helpers, mirrored to the bus
&lt;/h2&gt;

&lt;p&gt;The whole thing runs on just &lt;strong&gt;two input_boolean helpers&lt;/strong&gt;. One is the main arm/disarm toggle; the other is an "alarm ready" flag that's only true when every monitored opening is closed. Both are mirrored onto the KNX bus, which is what keeps the physical world and Home Assistant in agreement. The wall keypad — a Tecnoalarm unit — talks KNX. When someone arms at the keypad, the group address flips, and the HA helper follows; when I toggle the helper in the dashboard, the keypad's state follows back.&lt;/p&gt;

&lt;p&gt;I'm deliberately keeping that bridge conceptual: &lt;strong&gt;Tecnoalarm keypad → KNX → HA helper&lt;/strong&gt;, nothing more. The point worth taking away is the pattern, not my group-address map. A plain boolean as the shared source of truth between a dedicated panel and your smart-home stack is clean, debuggable, and vendor-agnostic. Whatever flips the boolean — keypad, dashboard button, automation — the rest of the system just watches the boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The status grid: a Jinja-templated map of the house
&lt;/h2&gt;

&lt;p&gt;The centrepiece is a stock &lt;strong&gt;markdown card&lt;/strong&gt; with a Jinja template per sensor. Green dot means closed and safe; red means open and would-trigger. No custom integration, no HACS card — just markdown and the templating engine HA already ships. The pattern is trivially repeatable, one line per opening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;is_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'binary_sensor.garage_door_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'on'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;🔴&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;🟢&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt; Garage
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;is_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'binary_sensor.window_office_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'on'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;🔴&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;🟢&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt; Office
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are &lt;strong&gt;12 monitored window and door sensors&lt;/strong&gt; in total — 7 on the ground floor, 5 upstairs — and I group them by floor in both the grid and the logbook so the panel reads like the building rather than a flat entity dump. (I'm showing two generic example sensors here; the real grid uses anonymised English ids, never the room-by-room inventory.) The garage door is just another contact in that list, which matters more than it sounds: a garage-entry attempt rides exactly the same arm/notify/log pipeline as any window.&lt;/p&gt;

&lt;h2&gt;
  
  
  A naive trigger fires on every glitch
&lt;/h2&gt;

&lt;p&gt;Each monitored opening gets its own automation — a state trigger from off to on — rather than one big template. Per-sensor automations mean per-sensor log messages and notifications, which is worth the extra YAML. My first version fired the alarm the instant a contact reported open. That was a mistake. Contacts glitch. A momentary flap, a sensor settling, a draft, and suddenly the house is lit up and phones are buzzing at 3 a.m. for nothing.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;for:&lt;/strong&gt; duration on the trigger: only fire if the contact &lt;strong&gt;stays&lt;/strong&gt; open. Start with a short, tunable value while you watch behaviour — five seconds reads fine on paper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.garage_door_status&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off"&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After living with real false alarms, my actual setting climbed to &lt;strong&gt;30 seconds&lt;/strong&gt; of verify-and-recheck before the alarm commits. That is the tunable value doing its job: long enough to swallow glitches, short enough that a genuine entry still pages me well before anyone's settled in. The 5-second value is a starting point; the right number depends on how noisy your contacts are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dual logging so events survive a glance
&lt;/h2&gt;

&lt;p&gt;When the alarm does commit, it fans out on multiple channels: critical push notifications to the household phones, an audible siren, and lights snapping on in the circulation areas — kitchen, staircase, hallways — so a break-in is visibly &lt;strong&gt;seen&lt;/strong&gt; rather than silent. I'm keeping the exact siren entity and light circuits out of this on purpose and only describe the response.&lt;/p&gt;

&lt;p&gt;The part I'd recommend to anyone is the logging. Every trigger writes &lt;strong&gt;both&lt;/strong&gt; a logbook.log entry (named "Alarm Event", with the sensor and a verification note) &lt;strong&gt;and&lt;/strong&gt; a persistent_notification that sits in the bell menu until I dismiss it. One is the timeline; the other is the sticky "hey, look at this" that survives a casual glance at the phone. The dashboard surfaces this directly: a 24-hour logbook card filtered to exactly the alarm sensors, plus an 8-hour history-graph of the busiest contacts for spotting patterns. With both in place, an event stays visible until I have looked at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick actions, a mobile frame, and where this sits
&lt;/h2&gt;

&lt;p&gt;Three buttons handle the day-to-day: Arm, Disarm, and a jump-to-Logbook. Arm and Disarm call &lt;strong&gt;input_boolean.turn_on&lt;/strong&gt; / &lt;strong&gt;turn_off&lt;/strong&gt; on the same helper the KNX keypad flips, and the logbook button is a plain navigate action. Because everything routes through that one boolean, the dashboard disarm and the physical keypad disarm are literally the same operation.&lt;/p&gt;

&lt;p&gt;The whole panel is built mobile-first — touch-sized buttons (icon height around 40px), a scrollable logbook, a collapsible status list — so it works as a phone wall-panel by the door. The full config lives in the conventional HA files: automations, an input_booleans file, and a knx file mapping the helpers onto group addresses, all under git so I can see exactly what changed and when.&lt;/p&gt;

&lt;p&gt;One trade-off that I think people often get backwards. A Home Assistant dashboard is a fantastic &lt;strong&gt;operator console&lt;/strong&gt; — at-a-glance house state, a searchable history, one-tap arm/disarm, lights-and-siren choreography you'd never get from a standalone box. It is not a single source of truth for security. It depends on a server, a network, and software I'm actively tinkering with; a dedicated alarm panel is purpose-built to keep working when all of that falls over.&lt;/p&gt;

&lt;p&gt;The HA layer complements the dedicated system. The Tecnoalarm panel is what I rely on; the dashboard is what makes the house legible to me day to day. Writing about it in public meant deciding which details belong on which side of that line. If you want the groundwork this sits on, the earlier parts of this series cover &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;running Home Assistant in Docker&lt;/a&gt; and getting the wider stack talking.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>smarthome</category>
      <category>dashboard</category>
      <category>security</category>
    </item>
    <item>
      <title>Replacing Algolia with an In-Memory Search in Next.js 14</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 28 Aug 2026 10:17:26 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/replacing-algolia-with-an-in-memory-search-in-nextjs-14-2m1m</link>
      <guid>https://dev.to/cloudapp_dev/replacing-algolia-with-an-in-memory-search-in-nextjs-14-2m1m</guid>
      <description>&lt;p&gt;&lt;strong&gt;The most satisfying commit of our migration off Contentful was the one that only deleted things.&lt;/strong&gt; Out went the Algolia client, the sync route that pushed every published post to a hosted index, and four environment variables I no longer had to keep secret. On-site search for cloudapp.dev now lives in a single file with no service behind it — and I want to walk through the reasoning, including the one part that actually needed thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Algolia wasn't the problem — our size was
&lt;/h2&gt;

&lt;p&gt;I'll say it plainly: Algolia is good software, and I'd reach for it again on a large catalog. But our "catalog" is about 220 blog posts. Every publish still had to fan out to an external index, which means a sync job, a webhook, and a low-grade recurring worry — is the index actually in step with what's live, or is some post quietly missing from search? We were maintaining a second copy of our content, and paying for it, to search a dataset that fits comfortably in RAM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cloudapp.dev/improving-your-website-visibility-the-importance-of-nextjs-seo" rel="noopener noreferrer"&gt;When we cut over from Contentful to a self-hosted headless CMS&lt;/a&gt;, one detail made the decision for me: the adapter already holds everything in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The store is already in memory
&lt;/h2&gt;

&lt;p&gt;On the first request to a server instance, the adapter pulls the whole space — every record, asset and tag, about 8 MB of JSON — and memoizes it for that instance's lifetime. A small aside that turned out to matter: that payload is big enough that Next.js flatly refuses to put it in its data cache (you get a cheerful &lt;strong&gt;items over 2MB can not be cached&lt;/strong&gt; in the logs). Which is fine, because I never wanted a second cache layer — the in-memory store is the cache. Search over that isn't an I/O problem. It's a string match over an array we're already holding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// already in memory&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// built once per (store, locale)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scorePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;toHit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest cost is the first request to a cold instance, which pays for that ~8 MB fetch; after that the store is warm and every search — and every tag-cloud render — is essentially free. For a site that ships a few times a week, that's a trade I'll take all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring is deliberately boring
&lt;/h2&gt;

&lt;p&gt;Each post is flattened once into a lowercased &lt;strong&gt;haystack&lt;/strong&gt; — title, description, body and tags concatenated. Then the ranking does the obvious thing: weight a hit by which field it landed in, and require every term to appear somewhere, so a two-word query narrows the results instead of widening them.&lt;/p&gt;

&lt;p&gt;The weights aren't tuned with any science — a title hit (8) should simply outrank a body hit (2), because if your query words are in the title, that post is almost certainly the one you meant. The same in-memory pass also feeds the homepage tag cloud: &lt;strong&gt;computeFacets()&lt;/strong&gt; just counts tags across the same posts, so search results and facets can never disagree about what exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;W_TITLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W_SHORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W_TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W_CONTENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scorePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IndexedPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;term&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// AND: every term required&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;W_TITLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shortDescription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;W_SHORT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;W_TAG&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;W_CONTENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The only part I actually had to think about
&lt;/h2&gt;

&lt;p&gt;The search itself was the easy twenty lines. The part I sat with was making sure the index can never lie. So I memoize the built index on a &lt;strong&gt;WeakMap&lt;/strong&gt; keyed by the store object itself. When a content webhook fires and we swap the store via &lt;strong&gt;resetStore()&lt;/strong&gt;, the old store object becomes unreachable — and its WeakMap entry goes with it, collected by the GC. No &lt;strong&gt;resetSearchIndex()&lt;/strong&gt; to remember to call, no TTL to guess at, no path by which the index can drift from the content it describes. It simply cannot outlive its source. That property was the whole point; the token scan was almost incidental.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd still pay for Algolia
&lt;/h2&gt;

&lt;p&gt;To be fair about the trade-off: this only works because the dataset is small and the matching is simple. There's no typo-tolerance, no synonyms, no stemming, no relevance tuning at scale. Index millions of documents or need fuzzy ranking, and a hosted engine earns every cent. But for a few hundred posts where people mostly type a framework name, exact substring matching with a handful of field weights is plenty — and "plenty" that I can read top-to-bottom in one file beats "sophisticated" that I have to keep operating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best part of the change was the diff: almost all red.&lt;/strong&gt; The SDK, the sync route and the env vars are gone, and search results and the homepage tag cloud now come from the same store snapshot the rest of the site already renders from. Deleting a dependency you've outgrown is one of the quieter pleasures of the job.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Tapo H100: Cellar Humidity Monitoring in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 07 Aug 2026 06:21:39 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/tapo-h100-cellar-humidity-monitoring-in-home-assistant-i39</link>
      <guid>https://dev.to/cloudapp_dev/tapo-h100-cellar-humidity-monitoring-in-home-assistant-i39</guid>
      <description>&lt;p&gt;Cellars sweat. On a warm humid day the air you let in is warmer than the cold concrete, and the moment it touches a cold surface it gives up its water. That's condensation, and over enough summers it's how a basement grows mould in the corners you never look at. I wanted a number that warned me before that happened. The catch I already knew going in: a raw relative-humidity reading isn't that number.&lt;/p&gt;

&lt;p&gt;This is Part 08 of the series. The hub was already in the house running other things, and the install was genuinely the easy 20 minutes. The part worth writing about is what came after the sensor showed up: turning its two raw readings into a dew-point spread, and deciding when that spread means "act" versus "ignore the spike."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the H100, and why 868 MHz is the whole point
&lt;/h2&gt;

&lt;p&gt;The hub is a &lt;strong&gt;TP-Link Tapo H100&lt;/strong&gt;, a little smart hub that acts as a radio bridge for TP-Link's battery sensors — the &lt;strong&gt;T100&lt;/strong&gt; motion, the &lt;strong&gt;T110&lt;/strong&gt; contact, and the one I care about here, the &lt;strong&gt;T310&lt;/strong&gt; temperature/humidity sensor. Here's the load-bearing detail, and the reason I reached for this hub instead of a WiFi sensor: the Tapo sensors don't talk WiFi. They talk to the H100 over &lt;strong&gt;868 MHz sub-GHz radio&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That matters in a cellar more than anywhere else. Sub-GHz is long-range and punches through concrete and floors in a way 2.4 GHz WiFi simply doesn't. There's no WiFi worth having down in my cellar, and no interest in running a repeater into a damp room just to read a sensor. The T310 sits down there on a battery, the H100 upstairs where the network is, and the radio link between does the work. One H100 supports up to 64 sensors — for a house, more headroom than I'll ever use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the sensors into Home Assistant
&lt;/h2&gt;

&lt;p&gt;The native TP-Link integration doesn't expose the H100's child sensors — it's built for the plugs and bulbs. The one that works is the community &lt;strong&gt;Tapo Controller&lt;/strong&gt; integration (petretiandrea's &lt;strong&gt;TP-Link Tapo&lt;/strong&gt;), installed through &lt;a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant" rel="noopener noreferrer"&gt;HACS&lt;/a&gt;, the same custom-integration store I've leaned on throughout this series. If you've followed along, you already have HACS; this is one more repository to add.&lt;/p&gt;

&lt;p&gt;Setup is by the hub's local IP plus your &lt;strong&gt;Tapo cloud account&lt;/strong&gt; email and password — the honest catch worth flagging, since it means the initial pairing needs internet rather than a local-only token. Once paired, every sensor on the hub is auto-discovered. Each T310 shows up as two entities — a &lt;strong&gt;sensor.tapo_temperature_&lt;/strong&gt;* and a &lt;strong&gt;sensor.tapo_humidity_&lt;/strong&gt;* — plus a battery sensor per device. I renamed the cellar one's children by location, so I'm working with &lt;strong&gt;sensor.tapo_humidity_keller&lt;/strong&gt; rather than a random suffix. Do that early; it makes every template downstream readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relative humidity is the wrong trigger — dew point is the right one
&lt;/h2&gt;

&lt;p&gt;Here's the engineering beat. Alert on "humidity above 70%" and you get a sensor that screams every muggy afternoon and tells you nothing about whether the cellar is actually in danger. Relative humidity is relative to temperature; 75% RH in a cool cellar and 75% RH in a warm room are completely different amounts of water in the air. What actually predicts condensation is the &lt;strong&gt;dew point&lt;/strong&gt; — and the spread between it and the coldest surface in the room. When that spread gets small, you're close to the wall going wet.&lt;/p&gt;

&lt;p&gt;Home Assistant doesn't hand you a dew point. But it hands you temperature and humidity, and dew point is arithmetic on top of those — the Magnus formula. This is exactly the pattern I've used elsewhere in my config: I already keep a &lt;strong&gt;templates.yaml&lt;/strong&gt; where raw sensors get turned into derived ones — scaling a raw boiler register, computing PV percentages like self-consumption and autarky. A cellar dew-point sensor is the same move, with a different equation, defined as a template sensor with its own &lt;strong&gt;unique_id&lt;/strong&gt;, unit, and &lt;strong&gt;device_class&lt;/strong&gt;. Here's the cellar one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Derived dew-point sensor for the cellar T310 (templates.yaml style)&lt;/span&gt;
&lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Taupunkt&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Keller"&lt;/span&gt;
        &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;taupunkt_keller&lt;/span&gt;
        &lt;span class="na"&gt;unit_of_measurement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;°C"&lt;/span&gt;
        &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;temperature&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;{% set t = states('sensor.tapo_temperature_keller') | float(0) %}&lt;/span&gt;
          &lt;span class="s"&gt;{% set rh = states('sensor.tapo_humidity_keller') | float(0) %}&lt;/span&gt;
          &lt;span class="s"&gt;{% set a = 17.27 %}{% set b = 237.7 %}&lt;/span&gt;
          &lt;span class="s"&gt;{% set g = (a * t) / (b + t) + log(rh / 100) %}&lt;/span&gt;
          &lt;span class="s"&gt;{{ ((b * g) / (a - g)) | round(1) }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A word on the thresholds, to be honest about what's measured versus guidance. These are general building-science values, not something I calibrated against my own walls: relative humidity becoming a concern around 65–70% RH when sustained, mould risk climbing once surface RH sits above roughly 70–80%, and a dew-point spread under about 3 °C as the real "condensation imminent" line. Treat them as starting dials, not gospel. The point of computing dew point locally is that you &lt;strong&gt;can&lt;/strong&gt; tune them — you own the formula and the trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the alert fire on a real problem, not a spike
&lt;/h2&gt;

&lt;p&gt;The other decision worth more than the formula: timing. Humidity is noisy — open a door, dry some laundry nearby, and the reading jumps for a few minutes then settles. If the automation fired on a single high sample I'd train myself to ignore it within a week. So the trigger only fires on a &lt;strong&gt;sustained&lt;/strong&gt; breach — humidity above threshold held for a continuous 30 minutes. That debounce is the difference between an alert I trust and one I mute.&lt;/p&gt;

&lt;p&gt;And then the part I genuinely like about this hub: the alert needs no extra hardware. The H100 isn't just a passive bridge — it exposes a switch for its built-in &lt;strong&gt;siren&lt;/strong&gt;, a select for the alarm sound, a number for volume, and an RGB &lt;strong&gt;night-light ring&lt;/strong&gt;. So a humidity warning can drive the hub's own light: flash it red when the cellar crosses the line. No buzzer to wire, no smart bulb to buy — the thing that bridges the sensor also announces the problem. That's three escalation paths I get for free: a Home Assistant push, the siren switch, and the RGB ring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Flash the H100's own light red when cellar humidity is too high&lt;/span&gt;
&lt;span class="na"&gt;automation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Keller:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Feuchtigkeit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Warnung"&lt;/span&gt;
    &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;numeric_state&lt;/span&gt;
        &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sensor.tapo_humidity_keller&lt;/span&gt;
        &lt;span class="na"&gt;above&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00:30:00"&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.tapo_h100_light&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;rgb_color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;255&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;brightness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;128&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That red is a deliberate &lt;strong&gt;[255, 0, 0]&lt;/strong&gt; at brightness 128 — bright enough to catch your eye on the stairs, not a floodlight. For a slow-moving problem like cellar damp, a quiet push plus a red glow is plenty; the siren I keep for things that actually want to be loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  When sensors don't show up
&lt;/h2&gt;

&lt;p&gt;Three things account for almost every "my T310 isn't in Home Assistant" moment. Most often the sensor simply isn't paired to the H100 in the Tapo app yet — the integration only sees what the hub already knows, so pairing happens in the app first, discovery second. Next is a flat battery; these are coin-cell devices and a dead one just goes silent. And sometimes Home Assistant needs a restart to pick up a freshly paired device. When none of that explains it, I filter the log to &lt;strong&gt;tapo&lt;/strong&gt; and read what the integration is actually complaining about — usually an auth hiccup against the cloud account or a hub it can't reach.&lt;/p&gt;

&lt;p&gt;Worth being fair about the trade-offs. The cloud-account login is the annoying part of an otherwise local setup; the 64-sensor ceiling is a non-issue for a house. But the radio is the reason this works at all: a battery sensor on the wrong side of a concrete wall, reporting periodically rather than being polled, reaching a hub upstairs over 868 MHz — a problem WiFi sensors don't solve. The dew-point template and the 30-minute debounce were the new bits I built on top; the hub and its auto-discovered entities were already running. That's the honest shape of it: a small cheap sensor and a bit of arithmetic turned a reading I couldn't act on into a warning I can.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>smarthome</category>
      <category>sensor</category>
      <category>automation</category>
    </item>
    <item>
      <title>Shelly in Home Assistant: Local Switching &amp; Energy Metering</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 04 Aug 2026 18:42:17 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/shelly-in-home-assistant-local-switching-energy-metering-2fjg</link>
      <guid>https://dev.to/cloudapp_dev/shelly-in-home-assistant-local-switching-energy-metering-2fjg</guid>
      <description>&lt;p&gt;I didn't put Shelly Plus 1PM and 2PM behind my wall switches for the app or the cloud. I put them there because each one quietly does two jobs at once: it switches a circuit locally, and it measures exactly what that circuit draws. The moment I had them wired in, every light group and every roller shutter in the flat became both controllable AND measurable in Home Assistant — with no separate energy meter and, deliberately, no cloud in the loop.&lt;/p&gt;

&lt;p&gt;This is the part most Shelly write-ups skip: the metering isn't a bonus, it's the reason to use them. This is how I set them up, kept them fully local, wired the physical switches so they still work like switches, and pulled per-channel energy into the HA Energy Dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Plus series for in-wall use
&lt;/h2&gt;

&lt;p&gt;The Plus 1PM and 2PM are Gen2 devices. That matters: they speak a local RPC API over HTTP and WebSocket, they're supported by the native Home Assistant Shelly integration out of the box, and nothing about them needs the internet once they're on your Wi-Fi. The 1PM is a single relay with power metering — I use it behind light circuits. The 2PM has two relays with metering and a dedicated cover/roller-shutter mode — that's what sits behind my shutters.&lt;/p&gt;

&lt;p&gt;The catch you plan for up front: both need a neutral wire in the switch box. Older installations often don't have one at the switch, so check before you order. If the neutral is there, the rest is easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing I change: kill the cloud, pin the IP
&lt;/h2&gt;

&lt;p&gt;Straight out of the box I do two things in the Shelly web UI (just browse to the device's IP). First, I disable Bluetooth and Cloud under Settings — I don't want the device reaching out to anything, and Home Assistant talks to it directly on the LAN. Second, I give it a fixed address via a DHCP reservation on my router, so the native integration never loses it after a reboot.&lt;/p&gt;

&lt;p&gt;That's the whole hardening story. No firmware flashing, no third-party firmware — stock Shelly firmware with the cloud switched off is already a fully local device. It keeps working if my internet drops, which for anything wired into a wall is exactly what I want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding them to Home Assistant (no MQTT required)
&lt;/h2&gt;

&lt;p&gt;The native Shelly integration finds Gen2 devices over mDNS. In Home Assistant I go to Settings, Devices &amp;amp; Services, and the Plus 1PM/2PM usually show up under discovered — one click to add. If they don't auto-discover, adding by IP works just as well. Each device brings in a switch entity per channel plus sensors for power and cumulative energy.&lt;/p&gt;

&lt;p&gt;You can run them over MQTT instead, and I do that for a couple where I want the reporting cadence fully under my control. But for most people the native integration is the right answer: it's local push, it's zero-config, and it exposes the energy sensors HA's dashboard needs. MQTT is the escape hatch, not the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch input mode that makes or breaks it
&lt;/h2&gt;

&lt;p&gt;Here's the setting that decides whether this feels great or fights you: the input mode. By default the physical switch directly toggles the relay ("follow"). That's fine for a dumb light. But behind a switch I want to automate, I set the input to detached — now the wall switch no longer hard-wires the relay; it just sends a button event to Home Assistant, and HA decides what happens. The relay still switches on command, so nothing is "lost" — the physical switch becomes an input, not a hard override.&lt;/p&gt;

&lt;p&gt;For a 2PM in cover mode you leave the inputs mapped to up/down, but for the 1PM behind a lamp, detached plus a Home Assistant automation is what lets one switch do more than one thing (single press = this light, double press = the whole room).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# One wall button, two actions — Shelly Plus 1PM input detached&lt;/span&gt;
&lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hallway switch&lt;/span&gt;
&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event.hallway_shelly_input_0&lt;/span&gt;
    &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_type&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single_push&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event.hallway_shelly_input_0&lt;/span&gt;
    &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_type&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;double_push&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;double&lt;/span&gt;
&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;choose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;trigger.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'single'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.toggle&lt;/span&gt;
            &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.hallway&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;trigger.id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'double'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_off&lt;/span&gt;
            &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;all&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Per-channel energy in the Energy Dashboard
&lt;/h2&gt;

&lt;p&gt;This is the payoff. Every Plus device reports active power in watts and a cumulative energy total per channel. In Home Assistant that total energy sensor is exactly what the Energy Dashboard wants: Settings, Dashboards, Energy, then add each Shelly channel under "Individual devices". Suddenly the hallway lights, the office circuit and the bathroom shutter each show up as their own slice of the day's consumption.&lt;/p&gt;

&lt;p&gt;What surprised me: it wasn't the lights that mattered, it was the standing draw. A 2PM behind a shutter that hadn't moved in three days was still showing a small constant wattage — the little things you only find once you can actually see per-circuit numbers instead of one house total. That per-room visibility is the whole reason I'd tell anyone to spend the extra few euros on the PM variant over a plain relay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roller shutters on the 2PM
&lt;/h2&gt;

&lt;p&gt;For shutters, the 2PM's cover mode turns the two relays into up/down with a calibration run so Home Assistant gets real position control — a cover entity with a 0-to-100 percent slider, not just up/stop/down. Run the calibration once from the Shelly UI (it times the full travel), and after that HA can send "close to 40 percent" and mean it. I drive mine from sun elevation and an indoor temperature sensor so the south side closes before the afternoon heat rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;Two things cost me time. First, power readings: the Plus PM devices report real active power, but if you're comparing against an old cloud screenshot or a non-PM Shelly, don't expect the numbers to line up — apparent versus active power is a real difference on inductive loads. Second, keep firmware current but read the changelog before a mass update; a Gen2 firmware bump once changed an entity's unique_id behaviour and I'd rather stagger updates than have HA re-discover a dozen devices at once.&lt;/p&gt;

&lt;p&gt;And the obvious one worth repeating: a neutral wire is non-negotiable for these. If your switch box doesn't have one, that's a wiring job before it's a Home Assistant job.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need a Shelly account or the cloud?
&lt;/h3&gt;

&lt;p&gt;No. With the native Home Assistant integration everything runs on your LAN. I disable Cloud on every device — Home Assistant discovers and controls them locally, and they keep working with the internet unplugged.&lt;/p&gt;

&lt;h3&gt;
  
  
  Native Shelly integration or MQTT — which should I use?
&lt;/h3&gt;

&lt;p&gt;Start with the native integration: it auto-discovers Gen2 devices, it's local push, and it exposes the energy sensors the dashboard needs. Reach for MQTT only if you want full control over the reporting cadence or you're consolidating everything onto one broker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I still use the physical wall switch?
&lt;/h3&gt;

&lt;p&gt;Yes — that's the point of detached input mode. The switch keeps working, but instead of hard-wiring the relay it sends a button event to Home Assistant, so one switch can trigger automations while the relay stays independently controllable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Shelly Plus 1PM/2PM turned every circuit in the flat into something I can both control and measure, locally, without adding a single cloud dependency. The metering side is what makes them an energy story, not just a switching one: they feed the same Energy Dashboard I use for solar and the inverter. If you're building that dashboard out, my writeups on pulling &lt;a href="https://www.cloudapp.dev/home-assistant-pv-self-consumption-autarky-sensors" rel="noopener noreferrer"&gt;PV self-consumption and autarky into HA&lt;/a&gt; and &lt;a href="https://www.cloudapp.dev/home-assistant-ac-thor-pv-surplus-hot-water" rel="noopener noreferrer"&gt;steering solar surplus into hot water&lt;/a&gt; pair naturally with per-room Shelly metering. And when you're hunting for the dashboard cards to visualise all of it, the &lt;a href="https://www.cloudapp.dev/hacs" rel="noopener noreferrer"&gt;HACS directory&lt;/a&gt; is where I go looking.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>smarthome</category>
      <category>energy</category>
      <category>iot</category>
    </item>
    <item>
      <title>I Built an Awesome List for Open-Source GEO Tools. The Licence Check Cut the Popular Ones.</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Thu, 30 Jul 2026 11:23:47 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/i-built-an-awesome-list-for-open-source-geo-tools-the-licence-check-cut-the-popular-ones-1b0</link>
      <guid>https://dev.to/cloudapp_dev/i-built-an-awesome-list-for-open-source-geo-tools-the-licence-check-cut-the-popular-ones-1b0</guid>
      <description>&lt;p&gt;Generative Engine Optimization is the unglamorous name for a real problem: your content now has to be findable, parsable and citable by systems that answer instead of link. ChatGPT, Perplexity, Claude, Google AI Overviews. The &lt;a href="https://arxiv.org/abs/2311.09735" rel="noopener noreferrer"&gt;original GEO paper&lt;/a&gt; from KDD 2024 showed the levers differ from classic SEO — citations, statistics and quotable structure move the needle where keyword density does essentially nothing.&lt;/p&gt;

&lt;p&gt;The tooling around it is almost entirely paid SaaS. There are two existing awesome-lists for GEO and both are good, but their tools sections read like a funding announcement: Profound, Otterly, Goodie, Semrush. Useful if you have a budget. Useless if you want to read the source and run it yourself.&lt;/p&gt;

&lt;p&gt;So I built the other half: &lt;strong&gt;&lt;a href="https://github.com/cloudapp-dev/awesome-open-geo" rel="noopener noreferrer"&gt;Awesome Open GEO&lt;/a&gt;&lt;/strong&gt; — 62 entries, one criterion, &lt;code&gt;awesome-lint&lt;/code&gt; clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The criterion
&lt;/h2&gt;

&lt;p&gt;Every entry has to be &lt;strong&gt;free to run &lt;em&gt;and&lt;/em&gt; published under an OSI-approved licence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sounds like a formality. It is the entire list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cut
&lt;/h2&gt;

&lt;p&gt;Two of the most-linked tools in this space didn't make it, and explaining why is more useful than the list itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/firecrawl/llmstxt-generator" rel="noopener noreferrer"&gt;firecrawl/llmstxt-generator&lt;/a&gt;&lt;/strong&gt; — 534 stars, genuinely handy, generates &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; for any site. It has &lt;strong&gt;no &lt;code&gt;LICENSE&lt;/code&gt; file&lt;/strong&gt;. Not a restrictive one. None. Under default copyright law that means all rights reserved: you have no granted right to use, modify or redistribute it, however public the repo is and however obviously the authors intended you to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Canonry/canonry" rel="noopener noreferrer"&gt;Canonry&lt;/a&gt;&lt;/strong&gt; — 99 stars, an AEO monitoring platform, source fully readable. Licensed &lt;strong&gt;FSL-1.1-ALv2&lt;/strong&gt;, the Functional Source License. Source-available, converts to Apache-2.0 after two years, and a perfectly reasonable commercial choice. It is not open source today, and the OSI has been clear about that class of licence.&lt;/p&gt;

&lt;p&gt;Neither of those is a criticism of the projects. A missing licence file is usually an oversight, and FSL is a deliberate, defensible business decision. But a list that calls itself open-source has to actually check, or the label means nothing.&lt;/p&gt;

&lt;p&gt;I found the same pattern repeatedly: repos described as "open source" in their own README with no licence in the tree, and "open" platforms whose licence quietly excludes competing use. If you're picking a tool to build on, that distinction is the difference between a dependency and a liability.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the list was actually vetted
&lt;/h2&gt;

&lt;p&gt;I did not want to hand-pick from memory, so:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A GitHub API sweep&lt;/strong&gt; — about 15 topic and keyword queries (&lt;code&gt;topic:generative-engine-optimization&lt;/code&gt;, &lt;code&gt;topic:llms-txt&lt;/code&gt;, &lt;code&gt;topic:answer-engine-optimization&lt;/code&gt;, &lt;code&gt;topic:ai-visibility&lt;/code&gt;, plus keyword searches), producing &lt;strong&gt;603 unique repositories&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtered&lt;/strong&gt; on stars, licence SPDX id, last push, and archived status. Most of the 603 were false positives — user-agent parsers, Telegram bots, a C# cheat sheet that happened to mention &lt;code&gt;llms.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the README&lt;/strong&gt; of everything I planned to feature, because a repo description is marketing and a third-party blog summary is worse. That's how I learned OneGlanse drives the real chat UIs in a browser rather than calling model APIs — which turns out to be its whole point, and isn't in its description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity-checked the outliers.&lt;/strong&gt; A brand-new "SEO skill" repo with 12,000 stars deserves a look at its star-to-fork ratio and creation date before you vouch for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl&lt;/code&gt;'d every link&lt;/strong&gt; for a 200 before publishing. All 64.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's in it
&lt;/h2&gt;

&lt;p&gt;Ten sections, ordered so a newcomer can start at the top:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standards &amp;amp; Specifications&lt;/strong&gt; — &lt;code&gt;llms.txt&lt;/code&gt;, Schema.org, &lt;code&gt;ai.robots.txt&lt;/code&gt;, and Joost de Valk's Website Specification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit &amp;amp; Linting&lt;/strong&gt; — CLIs you run against your own site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility &amp;amp; Citation Monitoring&lt;/strong&gt; — the widest paid-SaaS gap, and where self-hosting matters most since you pay the inference either way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llms.txt Tooling&lt;/strong&gt; — generators and validators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework &amp;amp; CMS Integrations&lt;/strong&gt; — Nuxt, Astro, VitePress, MkDocs, Sphinx, Laravel, WordPress&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crawler Access &amp;amp; Log Analysis&lt;/strong&gt; — including Anubis, listed on purpose: you cannot be cited by a crawler you blocked, so access control is a GEO decision in both directions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent Skills &amp;amp; MCP Servers&lt;/strong&gt; — with the honest caveat that none of their scoring is calibrated against a benchmark&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research &amp;amp; Papers&lt;/strong&gt; — the KDD 2024 original, the 2026 survey covering 45 studies, AutoGEO, and a governance paper that argues against the whole enterprise&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Directories &amp;amp; Datasets&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Related Lists&lt;/strong&gt; — including the two broader GEO lists, because they complement this rather than compete with it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Contributing
&lt;/h2&gt;

&lt;p&gt;Self-submissions are welcome and don't need an apology — just disclose it in the PR. My own audit tool is in there exactly once, under the same rules, with no special placement.&lt;/p&gt;

&lt;p&gt;The bar is in &lt;a href="https://github.com/cloudapp-dev/awesome-open-geo/blob/main/contributing.md" rel="noopener noreferrer"&gt;contributing.md&lt;/a&gt;. Removals count as contributions too: if something on the list is abandoned, relicensed away from open source, or simply worse than it looked, open an issue and I'll cut it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo: &lt;a href="https://github.com/cloudapp-dev/awesome-open-geo" rel="noopener noreferrer"&gt;github.com/cloudapp-dev/awesome-open-geo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The niche is young enough that a lot of good work isn't on the list yet. If you maintain something that clears the bar, I'd rather hear it from you than find it in six months.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>ai</category>
      <category>seo</category>
    </item>
    <item>
      <title>KNX Motion-Sensor Automations in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:46:30 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/knx-motion-sensor-automations-in-home-assistant-3dj3</link>
      <guid>https://dev.to/cloudapp_dev/knx-motion-sensor-automations-in-home-assistant-3dj3</guid>
      <description>&lt;p&gt;&lt;em&gt;A note before the post: the mistake in the first section is genuinely mine. It cost me an evening of forking conditions in Home Assistant before I accepted the fix didn't belong in Home Assistant at all. I've left it in rather than writing around it, because it's the part I'd have wanted to read first.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first time motion-controlled lighting actually worked in my place, it didn't feel clever. It felt obvious — I walked into a dark hallway and the light was already on by the time I'd registered it was dark. That's the bar. Not &lt;strong&gt;smart&lt;/strong&gt;, just attentive. Getting there with seven KNX motion sensors took me less code than I expected and one insight I wish I'd had on day one.&lt;/p&gt;

&lt;p&gt;This is Part 05 of the series. The earlier parts cover the boring-but-load-bearing groundwork: &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;running Home Assistant in Docker&lt;/a&gt; and &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-hacs" rel="noopener noreferrer"&gt;wiring up HACS&lt;/a&gt;. Here I'm assuming HA is up, talking KNX, and you just want the lights to behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  One sensor, two jobs, two addresses
&lt;/h2&gt;

&lt;p&gt;Here's the mistake I made, and it's the whole reason this post exists. KNX exposes each motion sensor to Home Assistant as a &lt;strong&gt;binary_sensor&lt;/strong&gt; with &lt;strong&gt;device_class: motion&lt;/strong&gt;, fed by a KNX group-address state object you configure in &lt;strong&gt;knx.yaml&lt;/strong&gt; with a &lt;strong&gt;state_address&lt;/strong&gt; per sensor. Simple enough. So I wired all seven sensors with one group address each and pointed both the lighting automation and the presence logic at the same signal.&lt;/p&gt;

&lt;p&gt;That works right up until you want the two to behave differently. A light should react to the smallest twitch, instantly, generously. Presence and security want the opposite: a debounce, a grace window, some scepticism before they commit. When both ride the same group address, every change you make to one quietly deforms the other. I spent an evening forking conditions in Home Assistant trying to make one signal mean two things.&lt;/p&gt;

&lt;p&gt;The fix isn't in Home Assistant at all. It's in ETS: give each physical PIR a &lt;strong&gt;second group address&lt;/strong&gt;. One drives comfort lighting, the other feeds presence and the alarm path. I use a flat convention — &lt;strong&gt;5/4/x&lt;/strong&gt; for lighting, &lt;strong&gt;5/5/x&lt;/strong&gt; for the security path, &lt;strong&gt;5/6/x&lt;/strong&gt; reserved for generic presence. Now "the light reacts" and "security reacts" are genuinely separate events. The lighting automations below never touch the alarm side, which is exactly how I want it — this post is strictly about lights and presence, and the alarm internals stay out of it on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dumb version first
&lt;/h2&gt;

&lt;p&gt;Start with the on/off automation that everyone writes. Motion goes from off to on, turn the light on. Motion goes off, turn the light off. Two automations, a sensor and a light:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Motion ON -&amp;gt; light on; motion OFF for 5 min -&amp;gt; light off&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hallway&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;light&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;motion"&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
      &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.flur_motion&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off"&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.flur&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hallway&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;light&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;min"&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
      &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.flur_motion&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off"&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_off&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.flur&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are doing quiet work here. &lt;strong&gt;mode: single&lt;/strong&gt; keeps the automation from re-triggering itself into a storm when a sensor chatters — pair it with &lt;strong&gt;max_exceeded: silent&lt;/strong&gt; so the overlap is swallowed without filling your log. And the off side triggers on the sensor going to off with a &lt;strong&gt;for:&lt;/strong&gt; of five minutes, not immediately. That &lt;strong&gt;for:&lt;/strong&gt; is the single most important number in the whole setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest failure: lights dropping while you're still there
&lt;/h2&gt;

&lt;p&gt;Every motion-lighting setup fails the same way the first week: you're standing perfectly still reading something, the sensor stops seeing you, and the room goes dark. It's the classic failure and it's infuriating because it makes the house feel like it's sulking at you.&lt;/p&gt;

&lt;p&gt;The instinct is to fight the sensor — chase faster polling, a more sensitive PIR, fancier presence detection. Don't. The fix is almost always to lengthen the off-delay. I settled on a small taxonomy that maps to how long people actually linger in a space: short hold of about 30 seconds to a minute for small rooms you pass through, medium of 3 to 5 minutes for halls and stairs, and long of 10 to 15 minutes for living rooms and offices where you sit still. A hallway gets five minutes. A reading chair does not get thirty seconds.&lt;/p&gt;

&lt;p&gt;I also give the trigger a 2 to 5 second grace window before acting, so a single flicker on the wire doesn't strobe the room. Cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The touches that make it feel intentional
&lt;/h2&gt;

&lt;p&gt;Once the lights stop misbehaving, you can add the bits that make it feel like the house was designed rather than scripted. None of these are hard; they're just thoughtful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Night-only stairs.&lt;/strong&gt; The stairwell and hallway lights only auto-fire in the dark. A &lt;strong&gt;condition: sun&lt;/strong&gt; with after sunset / before sunrise gates them, so they don't pointlessly switch on at noon when there's plenty of daylight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-of-day brightness.&lt;/strong&gt; A fixed brightness is a tell that a machine wrote your house. Full blast at 3am is hostile. I drive brightness with a &lt;strong&gt;choose:&lt;/strong&gt; block keyed on time windows — roughly 30% late at night (22:00 to 06:00), 70% in the early morning (06:00 to 08:00), and 100% during the day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Time-of-day brightness with choose:&lt;/span&gt;
&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;choose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
            &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;22:00:00"&lt;/span&gt;
            &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06:00:00"&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
            &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stairs&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stairs&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pathway lighting up the stairs.&lt;/strong&gt; My favourite trick. The stair automation chains two lights with a &lt;strong&gt;delay:&lt;/strong&gt; of about three seconds between them — the lower light first, then the floor above a beat later — so the light walks up the stairs ahead of you instead of flooding the whole shaft at once. It's a tiny thing that gets noticed every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A whole-zone energy cutoff.&lt;/strong&gt; The thing I'm proudest of, because it's where the naive version quietly wastes power. Instead of each light timing out alone, an energy-saving automation triggers on a list of motion sensors all going to off for a hold time — I use ten minutes across the group — and then re-asserts in its conditions that every single sensor in the zone is still off before it drops anything. That AND-of-all guard matters: one sensor flapping back to on shouldn't keep a whole floor lit, and one sensor going quiet shouldn't plunge an occupied zone into darkness. Lights only drop when the zone is genuinely empty. Driving it by &lt;strong&gt;area_id&lt;/strong&gt; rather than enumerating every fixture means one automation can switch off a whole zone at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cheap presence value, almost for free
&lt;/h2&gt;

&lt;p&gt;Here's a small one I keep reaching for. You don't always need a real presence-detection platform to answer "was anyone in the kitchen recently?". Give each area an &lt;strong&gt;input_datetime&lt;/strong&gt; helper, and add a one-line automation per motion sensor that, on going to on, stamps the current time into it. Now you have a per-area "last motion seen" value you can read anywhere — in conditions, on a dashboard, in a notification — without installing anything.&lt;/p&gt;

&lt;p&gt;It's not occupancy in the rigorous sense; it's a timestamp. But "last seen two minutes ago" answers nine out of ten of the questions I actually ask my house, and it costs one helper and one tiny automation per room.&lt;/p&gt;

&lt;p&gt;To tune any of this, put the sensors on a Lovelace &lt;strong&gt;type: entities&lt;/strong&gt; card so you can watch live motion state per area while you fiddle with the off-delays. Seeing a sensor sit on &lt;strong&gt;off&lt;/strong&gt; a full second before you expected it to is how you learn which room needs a longer &lt;strong&gt;for:&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Two things. First: split the signal at the bus, not in YAML. A second group address per sensor is five minutes in ETS and it saves you from building a tower of conditions that tries to make one event mean two contradictory things. Second: the entire quality of motion lighting lives in the off-delay, not the on-trigger. Turning a light on is trivial; turning it off at the right moment, after the right hold, only when the room is really empty, is the whole craft.&lt;/p&gt;

&lt;p&gt;Throughout this I've kept entity names generic on purpose — flur, stairs, hallway — and the group-address scheme abstract. The real reason a lighting signal and a security signal get separate addresses is the alarm path that lives on the other one, and that's a story for a part of this series that very deliberately leaves out the internals. For lights, the recipe above is the whole thing. Next part picks up where the second group address leads.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
