<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Cloudapp-dev on Medium]]></title>
        <description><![CDATA[Stories by Cloudapp-dev on Medium]]></description>
        <link>https://medium.com/@cloudapp_dev?source=rss-045c5a6090e3------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*Xl68ADOHMqPUymZc1O5fNA.png</url>
            <title>Stories by Cloudapp-dev on Medium</title>
            <link>https://medium.com/@cloudapp_dev?source=rss-045c5a6090e3------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 03:52:03 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@cloudapp_dev/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Home Assistant Backups on S3-Compatible Storage, Not AWS]]></title>
            <link>https://levelup.gitconnected.com/home-assistant-backups-on-s3-compatible-storage-not-aws-cc226a0826b5?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/cc226a0826b5</guid>
            <category><![CDATA[s3]]></category>
            <category><![CDATA[self-hosting]]></category>
            <category><![CDATA[home-automation]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[backup]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Fri, 13 Nov 2026 17:40:45 GMT</pubDate>
            <atom:updated>2026-11-13T17:40:45.104Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*kcSD7GXwYvuwfbjO" /></figure><p>The Home Assistant instance I am rebuilding on a small Hetzner VM had been running for weeks, by then with real house logic on it, when I opened its backup settings during the migration and found zero backups. I have written before about <a href="https://www.cloudapp.dev/home-assistant-backup-strategy-full-host">what belongs in a backup of the whole host</a> and about <a href="https://www.cloudapp.dev/home-assistant-disaster-recovery-rebuild">rebuilding after a loss</a>. Those two posts cover what to back up and how to restore it. This post covers copying the Home Assistant backup to S3-compatible object storage outside AWS, and getting a notification when the latest local backup or the last completed run of the copy job is more than 30 hours old.</p><h3>What was missing</h3><p>The backup settings said automatic_backups_configured: false and listed no backups. The only thing that was saved was the whole VM, through Hetzner snapshots. My own setup notes for this VM already listed a config backup on Home Assistant level as open, with the remark that the VM snapshot does not replace it. I then moved real logic onto the instance without closing that item. I only found the gap because the migration made me read the settings page.</p><p>Object storage was already at hand. On the same day a media archive I run on the same machine had moved from Azure Blob Storage to Hetzner Object Storage, because the old upload logged in through the managed identity of an Azure VM, and a Hetzner VM has none. The bucket sits in the same project and at the same location as the VM. I created the bucket and the access keys in the Hetzner console. The hcloud CLI manages Storage Boxes and not Object Storage, and I did not script this step with an S3 tool.</p><h3>Why the built-in aws_s3 integration rejects other providers</h3><p>Home Assistant 2026.8 ships an integration called <strong>aws_s3</strong> as a backup location, and its setup form has an endpoint URL field that can be filled in freely. That looked like the obvious solution. The config flow of the integration contains this check, quoted from its source: if not hostname.endswith(“amazonaws.com”), the form returns invalid_endpoint_url. The field exists, and the validation behind it excludes every other S3-compatible provider.</p><p>So I went one level lower and split the job in two. Home Assistant writes its backup to the local disk, which it can do without any backup location integration, and a small script of my own handles the transport to the bucket.</p><h3>The setup: a local encrypted backup and a separate copy job</h3><p>Home Assistant now creates an encrypted automatic backup every day at 03:30 and keeps 7 of them locally. At 04:40 a systemd timer on the host starts the copy script inside the Home Assistant container. Running it there has a practical reason: boto3 is already part of the official Home Assistant container image, so the script needs no additional dependency and no second Python environment on the host.</p><pre><br># ha-backup-s3.timer<br># runs after the automatic Home Assistant backup at 03:30<br>[Timer]<br>OnCalendar=*-*-* 04:40:00<br>Persistent=true<br>Unit=ha-backup-s3.service<br><br># ha-backup-s3.service<br># the script runs inside the HA container, boto3 is already there<br>[Service]<br>Type=oneshot<br>ExecStart=/usr/bin/docker exec homeassistant python /config/tools/ha_backup_s3.py</pre><p>The script itself deletes nothing in the cloud. Retention is a lifecycle rule on the bucket that removes objects after 180 days, so Hetzner does the deleting, for the Home Assistant backups and for that archive alike. Locally Home Assistant cleans up on its own and keeps its 7. The access keys are in the password manager and in a secrets file on the VM, and the script reads them at run time. How the container gets at that file and how the S3 client is configured is not part of the excerpt below.</p><h3>How the copy is verified</h3><p>The check is the one that archive job uses before it is allowed to delete a file locally, and I took it over unchanged. After the upload the script reads the object’s metadata back and compares the size and the ETag with the local file. On Hetzner the ETag returned for a single PUT is the MD5 of the object. Other providers or encryption settings can behave differently, so check that before reusing the script. The script also checks that the SHA-256 it supplied during the upload is stored in the metadata. That last check confirms the metadata, and the stored content is only hashed again in the restore test further down. A backup that passed gets a receipt, and a file with a receipt is skipped on the next run.</p><pre><br># core of the copy job: upload, read back, compare, then write the receipt<br>for path in sorted(BACKUPS.glob(&quot;*.tar&quot;)):<br>    stat = path.stat()<br>    if receipts.get(path.name, {}).get(&quot;size&quot;) == stat.st_size:<br>        continue                          # already copied and verified<br>    md5, sha = digests(path)              # one pass over the file, both hashes<br>    key = f&quot;ha-backups/{path.name}&quot;<br>    with path.open(&quot;rb&quot;) as body:<br>        s3.put_object(Bucket=BUCKET, Key=key, Body=body,<br>                      ContentMD5=base64.b64encode(md5).decode(),<br>                      Metadata={&quot;sha256&quot;: sha})<br>    head = s3.head_object(Bucket=BUCKET, Key=key)<br>    if (head[&quot;ContentLength&quot;] != stat.st_size           # size<br>            or head[&quot;ETag&quot;].strip(&#39;&quot;&#39;) != md5.hex()     # ETag = MD5 for a single PUT<br>            or head[&quot;Metadata&quot;].get(&quot;sha256&quot;) != sha):  # SHA-256 from the object metadata<br>        raise RuntimeError(f&quot;verification failed for {key}&quot;)<br>    receipts[path.name] = {&quot;key&quot;: key, &quot;size&quot;: stat.st_size, &quot;sha256&quot;: sha}<br>    write_json(RECEIPTS, receipts)</pre><p>The archive job, which uses the same bucket, also checks before every run that an anonymous request is rejected with a 403. The first Home Assistant backup was 163 MB and its upload took 2 seconds with the VM and the bucket in the same location.</p><h3>Store the encryption key before setting it</h3><p>I first saved the encryption key in the password manager and read it back from there, and only then set it in Home Assistant. With the opposite order there is a period in which Home Assistant already encrypts backups with a key that exists in exactly one place.</p><p>After that I checked whether the backup can be opened at all. Home Assistant has a websocket command for this, backup/can_decrypt_on_download. With the key from the password manager it answered OK, and with a deliberately wrong key it rejected the request. This test confirms that the key fits the backup. Eight days later I ran a restore test against the copy in the object storage. A script downloads the newest backup, compares its size and SHA-256 with the storage metadata, and decrypts and unpacks it with the key from the password manager. The backup had grown to 2.15 GB by then. The download took 9 seconds and unpacking 3,012 files took 61 seconds. All 32 files under .storage parsed as JSON, 23 of 23 integrations and 16 of 16 package files matched the running instance, and the SQLite quick check of the database passed. I did not start a fresh Home Assistant from that backup, so the last step of a real restore is still untested.</p><h3>Monitoring the backup and the copy</h3><p>The timer runs on the host and starts the script in the container, so neither is part of Home Assistant itself. If the timer stops firing or the script fails every night, nothing inside Home Assistant changes, and the local backups keep appearing. So the copy job writes a small status file with the time of its last completed run, a command_line sensor reads that file, and a binary sensor collects the reasons why something is wrong: Home Assistant backup older than 30 hours, S3 copy older than 30 hours, plus the corresponding checks for the archive job. When the sensor has been on for 15 minutes, an automation sends a push notification with the list of reasons. That automation is not in the YAML below. The status file records when the copy job last completed, so a run that finds nothing new counts as well. The age of the newest Home Assistant backup is checked separately by the first condition.</p><pre><br># the copy job writes a status file, Home Assistant reads it<br>command_line:<br>  - sensor:<br>      name: &quot;Backup S3 status&quot;<br>      command: &quot;cat /config/backups/.s3-status.json&quot;<br>      value_template: &quot;{{ value_json.last_run_utc }}&quot;<br>      device_class: timestamp<br>      scan_interval: 600<br><br>template:<br>  # trigger-based: the reasons are computed once and used for state and attribute<br>  - triggers:<br>      - trigger: time_pattern<br>        minutes: &quot;/5&quot;<br>      - trigger: state<br>        entity_id:<br>          - sensor.backup_s3_status<br>          - sensor.backup_last_successful_automatic_backup<br>    variables:<br>      reasons: &gt;-<br>        {% set r = namespace(l=[]) %}<br>        {% set b = states(&#39;sensor.backup_last_successful_automatic_backup&#39;) | as_datetime %}<br>        {% if b is none or (now() - b).total_seconds() &gt; 30 * 3600 %}<br>          {% set r.l = r.l + [&#39;HA backup older than 30 h&#39;] %}<br>        {% endif %}<br>        {% set s = states(&#39;sensor.backup_s3_status&#39;) | as_datetime %}<br>        {% if s is none or (now() - s).total_seconds() &gt; 30 * 3600 %}<br>          {% set r.l = r.l + [&#39;S3 copy older than 30 h&#39;] %}<br>        {% endif %}<br>        {{ r.l }}<br>    binary_sensor:<br>      - name: &quot;Backup problem&quot;<br>        device_class: problem<br>        state: &quot;{{ reasons | count &gt; 0 }}&quot;<br>        attributes:<br>          reasons: &quot;{{ reasons }}&quot;</pre><p>I tested it with a simulated clock, by evaluating the template through the template API with now() + timedelta(days=3) in place of now(). All age checks fired. The test also found a hole in one of the archive checks: a source that had never archived anything on the new instance had no last success, so its 72-hour check never applied. That is the kind of silent failure the sensor is meant to report. It now counts from the starting point of the archive. My first draft of the sensor had a second flaw. Its state read its own previous attribute through this.attributes and was always one update behind. The version above is trigger-based and computes the list once in a variable.</p><h3>What went wrong so far</h3><p>The largest failure so far came on 8 October, when the sensor reported that the S3 copy was older than 30 hours. The media archive was stored under /config, and in a container installation folders under /config cannot be excluded from a Home Assistant backup. The archive had grown to 4.2 GB, the backup had gone from 1.2 GB on 2 October to 4.5 GB, and the 38 GB disk was full. The copy script stopped at that file because it only uploads up to 4 GB with a single PUT, so newer backups were not copied either. I moved the archive to its own volume outside /config, and the next backup was 68 MB. The script now skips a file that is too large, copies the rest and reports it, a size check that is not in the Python excerpt above, and a new sensor warns when less than 6 GB is free. Both new checks feed the same problem sensor as reasons of their own, and neither is in the YAML excerpt above. The restore test described above ran before this change, on the larger backup.</p><p>On the archive job the first upload of a run was rejected twice with AccessDenied. The same file went through on the next run, and three further runs showed no error, so I could not reproduce it and I do not know the cause. The protection worked as intended: the file stayed on the local disk and was uploaded an hour later. The uploader now makes short retries on PUT and HEAD and logs the error code.</p><p>Before the first core update on the new instance I also triggered a fresh encrypted backup with the service backup.create_automatic and only then changed the image tag. The update and rollback procedure for the container has <a href="https://www.cloudapp.dev/home-assistant-docker-update-rollback">its own post</a>.</p><h3>Frequently asked questions</h3><h3>Can the built-in aws_s3 backup integration be used with a non-AWS S3 provider?</h3><p>Not on Home Assistant 2026.8, the release I was running. The config flow rejects every endpoint whose hostname does not end in amazonaws.com.</p><h3>How long are the backups kept, and what deletes them?</h3><p>Home Assistant keeps the latest 7 automatic backups locally and removes older ones itself. In the bucket a lifecycle rule deletes the copies after 180 days.</p><h3>How can I check that an encrypted backup opens without restoring it?</h3><p>The websocket command backup/can_decrypt_on_download checks whether the key matches the backup. I also downloaded, decrypted and unpacked a copy to check its contents, but have not tested starting a new instance from it.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-backup-s3-compatible-storage"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cc226a0826b5" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/home-assistant-backups-on-s3-compatible-storage-not-aws-cc226a0826b5">Home Assistant Backups on S3-Compatible Storage, Not AWS</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Home Assistant Packages Instead of Include Files]]></title>
            <link>https://levelup.gitconnected.com/home-assistant-packages-instead-of-include-files-1f85733ee4a3?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/1f85733ee4a3</guid>
            <category><![CDATA[home-automation]]></category>
            <category><![CDATA[yaml]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[self-hosting]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Fri, 06 Nov 2026 16:41:53 GMT</pubDate>
            <atom:updated>2026-11-06T16:41:53.860Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*RIoCJI42sSQnt8Jk" /></figure><p>The Home Assistant config I am migrating away from is split by type, the way most guides show it: input_boolean.yaml, input_datetime.yaml, templates.yaml and one file per category under automations/. Together that is about 4,800 lines of YAML, of which about 2,900 lines are automations in 13 files. For the new instance, which runs 2026.8.2 in Docker on <a href="https://www.cloudapp.dev/home-assistant-21-releases-behind-rebuild">the machine I rebuilt the setup on</a>, I dropped that layout and put everything into packages, one file per area. The price is that package automations can only be edited in YAML. I ship the files with a deploy script that checks the configuration and restores the previous files when the check fails.</p><h3>Where the split by type stopped helping</h3><p>I wrote earlier about <a href="https://www.cloudapp.dev/splitting-home-assistant-automations-yaml">splitting automations.yaml into category files</a>, and for the problem I had then it worked. I could open the heating automations without scrolling past everything else. The eight files from that post have grown to 13 since. That split only ever covered automations. The helpers stayed in input_boolean.yaml, the time windows in input_datetime.yaml and the display sensors in templates.yaml, so one area of the house was spread over five to six files.</p><p>This started to matter with the migration, because I am moving the logic from the old instance to the new one step by step. I want to take over one area completely, test it and switch it over. With the old layout I have to collect the helpers, templates and automations of an area from five or six files before I can move it.</p><h3>One package per area</h3><p>A package bundles everything one area needs in a single file: input helpers, template sensors, automations, scripts, and where needed integrations such as modbus or rest. One line in configuration.yaml includes the whole directory, and the file name becomes the package name. The example below is shortened and uses placeholder names, but the shape is the one my real packages have.</p><pre><br># configuration.yaml<br>homeassistant:<br>  packages: !include_dir_named packages   # file name = package name<br><br># packages/example_area.yaml (shortened, placeholder names)<br>input_boolean:<br>  example_area_enabled:<br>    name: Example area enabled<br><br>template:<br>  - sensor:<br>      - name: &quot;Example area status&quot;<br>        state: &quot;{{ &#39;on&#39; if is_state(&#39;input_boolean.example_area_enabled&#39;, &#39;on&#39;) else &#39;off&#39; }}&quot;<br><br>automation:<br>  - id: example_area_evening<br>    alias: &quot;Example area: evening routine&quot;<br>    initial_state: false          # shadow mode while the old instance still runs this<br>    triggers:<br>      - trigger: time<br>        at: &quot;19:00:00&quot;<br>    actions:<br>      - action: light.turn_on<br>        target:<br>          entity_id: light.example</pre><p>The first thing I converted was the demo data the new instance had carried until then, 21 template entities. It used to be pulled in by name with a single !include and is now packages/demo.yaml like everything else. After that, each area of the old instance gets its own file as it is taken over.</p><h3>The price: no UI editor for package automations</h3><p>Automations that come from a package cannot be edited in the UI editor. They show up in the UI and I can read their traces, but saving works only in YAML. That suits the way I work, because I change automations in the repository and deploy them from there. Anyone who builds automations mainly by clicking should weigh this before switching.</p><h3>Shadow mode while two instances run</h3><p>Once the new instance is connected to KNX, both instances share the bus for the rest of the migration. If the same automation runs on both, every action happens twice, so covers move twice and two push notifications go out. Every automation I take over therefore arrives on the new instance with initial_state: false, which I call shadow mode. The switch happens per area and always in the same order: I disable the automations of that area on the old instance first and then enable them on the new one. The initial_state: false line has to be removed at that point, because otherwise the next restart switches them off again. The previous post in this series covers <a href="https://www.cloudapp.dev/home-assistant-migration-old-and-new-in-parallel">the first steps of that parallel run</a>.</p><p>I ordered the areas by risk. Energy and PV come first because that area only reads and controls nothing, then helpers and time control, lights, covers, heating, and after those cameras and notifications. At the time of this first step the new machine had no connection to the house at all, so automations could be transferred and validated but could not yet be tested against real devices.</p><h3>The deploy script</h3><p>On the old instance a deploy meant copying files with scp to /tmp and then running sudo cp into the config folder, by hand. The new instance gets one script in the repository with four steps. It backs up configuration.yaml and packages/ on the machine and keeps the last 10 states. It runs rsync for only the files that are maintained in git, so secrets, .storage and the themes and cards installed through HACS stay untouched. It runs check_config inside the container and does not reload if that fails. The fourth step is optional in the full script, a reload of selected domains over the REST API or a full restart. The shortened version below always reloads automations, has no dry-run mode and assumes that configuration.yaml and packages/ already exist on the machine. It does not reload helpers or templates. The full script takes a list of domains, for example — reload automation,input_boolean,template, and I name the domains that a change touches. A change to configuration.yaml or a new integration needs — restart.</p><pre><br>#!/usr/bin/env bash<br># deploy script, shortened; host, paths and URL are placeholders<br>set -euo pipefail<br>HOST=ha-host; REMOTE=/path/to/config; SRC=config; HA_URL=http://localhost:PORT<br><br># 1. back up the current config on the machine, keep the last 10 states<br>TS=$(date +%Y%m%d-%H%M%S)<br>ssh &quot;$HOST&quot; &quot;cd $REMOTE &amp;&amp; mkdir -p .deploy-backups/$TS \<br>  &amp;&amp; cp -a configuration.yaml packages .deploy-backups/$TS/ \<br>  &amp;&amp; ls -1dt .deploy-backups/*/ | tail -n +11 | xargs -r rm -rf&quot;<br><br># 2. transfer only what is maintained in git; only packages/ is mirrored<br>ssh &quot;$HOST&quot; &quot;mkdir -p $REMOTE/packages&quot;      # rsync --dry-run cannot create it<br>rsync -rlt &quot;$SRC/configuration.yaml&quot; &quot;$HOST:$REMOTE/&quot;<br>rsync -rlt --delete &quot;$SRC/packages/&quot; &quot;$HOST:$REMOTE/packages/&quot;<br><br># 3. check_config in the container; read the output as well as the exit code<br>CHECK=$(ssh &quot;$HOST&quot; &quot;docker exec homeassistant python -m homeassistant \<br>  --script check_config -c /config&quot; 2&gt;&amp;1) || RC=$?<br>echo &quot;$CHECK&quot;<br>if [[ -n &quot;${RC:-}&quot; ]] || grep -qi &quot;invalid config&quot; &lt;&lt;&lt;&quot;$CHECK&quot;; then<br>  # restore the backup, otherwise the next restart loads the broken file<br>  ssh &quot;$HOST&quot; &quot;cd $REMOTE &amp;&amp; rm -rf packages &amp;&amp; cp -a .deploy-backups/$TS/. .&quot;<br>  exit 1                                     # nothing is reloaded<br>fi<br><br># 4. optional reload over the REST API; the token goes over stdin<br>printf &#39;Authorization: Bearer %s\n&#39; &quot;$(cat .ha-token)&quot; | \<br>  ssh &quot;$HOST&quot; &quot;curl -fsS -X POST -H @- $HA_URL/api/services/automation/reload&quot;</pre><p>Only packages/ is mirrored, so a package I delete in the repository also disappears on the machine. The token for the reload call goes to the machine over stdin, because on the command line it would be readable in the process list for every process on the host.</p><h3>What the script caught</h3><p>The first trial run of the full script with — dry-run aborted. In a dry run rsync cannot create a target directory that it is supposed to look into afterwards, so the script now creates packages/ over SSH beforehand. The second surprise was a single file transferred under a different name: the dry-run output showed the source name where the target name should have been. I stopped guessing and now transfer that one file with ssh and cat. That transfer is not in the shortened example. I saw this with the rsync 2.6.9 from 2006 that macOS still ships, and I have not tried a newer version.</p><p>The more important finding came a day later. check_config reported an invalid configuration and still exited with success. The script looked only at the exit code and restarted Home Assistant. Since then it reads the output of check_config and aborts on the words Invalid config, and in that case it restores the backup from step one automatically. Before that change a deploy that failed the check left the faulty file on the machine, where the next restart would have loaded it. A transfer that fails halfway is still not covered, because the script exits before it reaches the restore. I tested both additions with a deliberately broken file, and the script detected it, aborted and rolled back.</p><p>Later the same day the hardened script was needed for real. While building a monitoring package I made an indentation error in the YAML. The script recognised it, aborted and restored the backup before anything was reloaded. After that day I would not deploy a config without the output check and the restore. It follows the same idea as the <a href="https://www.cloudapp.dev/home-assistant-docker-update-rollback">backup, test and rollback routine I use for container updates</a>, applied to the config files.</p><h3>Recorder limits from the first day</h3><p>The recorder settings went in at the same step. I keep 7 days of history instead of 10, and one counter is excluded. The reason is the old instance, where a single sensor, the telegram counter of the KNX interface, accounted for 98 % of all rows in the recorder database, 8.39 of 8.56 million in 7 days. It increases with every bus telegram, and each intermediate value was written as its own row.</p><pre><br>recorder:<br>  purge_keep_days: 7<br>  exclude:<br>    entities:<br>      # on the old instance this counter produced 98 % of all recorder rows<br>      - sensor.knx_telegram_counter<br>  # no include block: everything is recorded except this counter</pre><p>The old instance also had an include list next to the exclude block, on the assumption that it worked as a whitelist. On the new instance I want to record everything except this counter, so I use only an exclude list.</p><p>After the switch to packages a restart reached HTTP 200 after 4 s, measured once per second from docker compose restart. HTTP 200 does not mean that all integrations have finished loading. The instance then had 40 entities, 21 of them demo, and the log showed no errors or warnings apart from the usual note about HACS being a custom integration.</p><h3>Frequently asked questions</h3><h3>Can I still edit automations in the UI when they live in a package?</h3><p>No. Package automations and their traces are visible in the UI, but changes have to be made in the YAML file.</p><h3>How do I avoid double actions when two instances run the same automations?</h3><p>I transfer every automation with initial_state: false. Per area I then turn the automations off on the old instance, remove initial_state: false on the new one and enable them there.</p><h3>Is the exit code of check_config enough for a deploy script?</h3><p>In my case it was not. check_config reported an invalid configuration and still exited with code 0. My script now also searches the output for Invalid config and restores the backup when it finds it.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-packages-instead-of-includes"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1f85733ee4a3" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/home-assistant-packages-instead-of-include-files-1f85733ee4a3">Home Assistant Packages Instead of Include Files</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Home Assistant Migration: Old and New in Parallel]]></title>
            <link>https://levelup.gitconnected.com/home-assistant-migration-old-and-new-in-parallel-29d6f8336ce9?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/29d6f8336ce9</guid>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[home-automation]]></category>
            <category><![CDATA[knx]]></category>
            <category><![CDATA[self-hosting]]></category>
            <category><![CDATA[smart-home]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 14:27:34 GMT</pubDate>
            <atom:updated>2026-10-30T14:27:34.402Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*oLRINZjtJc_yOp1N" /></figure><p>This is the first post of a series about moving my Home Assistant setup from one instance to another. The old instance runs 2024.11.1 on an Azure VM, the new one runs 2026.8.2 in Docker on a Hetzner server ( <a href="https://www.cloudapp.dev/home-assistant-server-what-it-costs-to-run">what that server costs</a> is a separate post), and I wrote earlier about <a href="https://www.cloudapp.dev/home-assistant-21-releases-behind-rebuild">why I rebuilt instead of upgrading</a>. The old instance controls KNX lights, covers and heating and reads a PV inverter over Modbus, in about 4,800 lines of YAML. Both instances run side by side for a while, and the new one takes over one area at a time. This post covers the first steps on the real house.</p><h3>The rule for parallel operation</h3><p>Both instances are connected to the same KNX bus. If the same automation runs on both, every action happens twice: covers get two commands and my phone gets two push notifications. So every automation I carry over arrives on the new instance with <strong>initial_state: false</strong>. My notes call that shadow mode. I switch per area and always in the same order. I turn the automation off on the old instance first and enable it on the new one afterwards. On the new one I also remove initial_state: false from the YAML, otherwise the automation is off again after the next restart.</p><pre><br># a package on the NEW instance: carried over, loaded, switched off<br>automation:<br>  - id: outdoor_light_on<br>    alias: &quot;Timer: outdoor light on&quot;<br>    initial_state: false   # shadow mode, the old instance still switches this<br>    mode: single<br>    triggers:<br>      - trigger: time<br>        at: input_datetime.outdoor_light_start<br>    actions:<br>      - action: switch.turn_on<br>        target:<br>          label_id: timer_outdoor_light</pre><p>The areas are ordered by risk. Energy and PV came first, because that area mostly calculates and controls nothing on the new instance. Helpers and time-based automations follow, then light, covers and heating. Through all the steps in this post every automation that switches something in the house kept running on the old instance.</p><h3>Reading sensors without writing to the bus</h3><p>Automations on two instances can only be compared in a meaningful way if both see the same states. I carried over 166 of the 170 KNX entities, including the comments from the old config. Two things I left out on purpose. One is expose, because with it Home Assistant reports a state onto the bus by itself, and that is active sending. The other is four switches where an accidental tap in the new dashboard would have real consequences.</p><p>In current versions the KNX connection is a config entry and no longer YAML, so I set it up through the config flow over the REST API. The new instance got the second tunnel address of the KNX interface, and the old one keeps the first. Two things went differently from the plan. The config flow creates the connection with state polling enabled and starts it immediately. The option can only be changed afterwards in the options flow, and by then the new instance had already sent 96 read requests to the bus. Reads are harmless, and I had still planned to start without any.</p><p>The second surprise was that switching state polling off does not mean never reading. After the next restart there were another 52 read requests. A comment in the Home Assistant source spells it out as True -&gt; expire 60; False -&gt; init. Off therefore means reading once at start and never again afterwards. For zero read requests, every single entity needs <strong>sync_state: false</strong>, and then the states stay unknown until a telegram happens to come by. For shadow mode I kept init: one sync at start, while the ongoing polling stays with the old instance.</p><p>The KNX integration in this version stores telegrams in its own SQLite database, so I could count afterwards what the new instance had sent. The result was 148 read requests and 0 write commands.</p><p>PV needed a different approach, because the PV dongle tolerates only a few Modbus connections. The old instance has long been reading through a small Modbus cache server that polls the dongle every 10 seconds and serves several clients, and I attached the new instance to the same cache. All 17 sensors show the same values as on the old instance, with differences only in the last decimal place.</p><h3>Same entity ids on both instances</h3><p>Dashboards and automations of the old instance refer to entity ids. If the ids differ on the new instance, every reference has to be touched during the move. Ids are derived from names, and the old instance has years of manual renames. I wrote a small tool that reads the entity registry of both instances over the WebSocket API, matches entries by their unique_id and renames on the new instance. It renamed 28 entities, and afterwards 183 of 183 matched ids were identical.</p><p>Matching by unique_id alone is not enough for KNX. The integration uses the group address as unique_id, so a motion detector that switches a light and the light itself have the same unique_id in different domains. The first run would have renamed six motion detectors into lamps, and the key is now platform plus domain plus unique_id.</p><h3>The first automation that runs on both instances</h3><p>I expected light to be the biggest area, and Home Assistant turned out to have almost no logic there. The KNX motion detectors switch the lamps themselves, directly on the bus, so the light also works when Home Assistant is down. What Home Assistant had was 7 automations, one per detector, that write the time of the last motion into a helper for a dashboard overview, the pattern from <a href="https://www.cloudapp.dev/home-assistant-last-motion-sensor-input-datetime">this older post</a>. The seven differed in two names only, so they became one automation that derives the target helper from the entity id of the triggering detector. The shortened example below lists two of the seven detectors. Each detector needs an existing date and time helper with the matching suffix.</p><pre><br># one automation instead of seven, active on both instances<br>automation:<br>  - id: remember_last_motion<br>    alias: &quot;Remember last motion per detector&quot;<br>    mode: parallel           # two detectors in the same second<br>    triggers:<br>      - trigger: state<br>        entity_id:<br>          - binary_sensor.motion_detector_a<br>          - binary_sensor.motion_detector_b<br>        from: &quot;off&quot;          # old: only to &quot;on&quot;, which also matched unavailable -&gt; on<br>        to: &quot;on&quot;<br>    actions:<br>      - action: input_datetime.set_datetime<br>        target:<br>          entity_id: &gt;-<br>            {{ trigger.entity_id | replace(&#39;binary_sensor.motion_detector_&#39;, &#39;input_datetime.last_motion_&#39;) }}<br>        data:<br>          datetime: &quot;{{ now().strftime(&#39;%Y-%m-%d %H:%M:%S&#39;) }}&quot;</pre><p>The trigger changed as well. The old automations reacted to on without a from, so they also fired when a detector changed from unavailable to on after a restart or a KNX reconnect. On the old instance, the last-motion value of one detector stood at exactly the second in which the KNX integration had been reloaded that evening.</p><p>This automation is active on both instances right away. It writes only to helpers of its own instance and sends nothing to the bus, so two instances recording the same thing do not interfere. I copied the helper values from the old instance over the REST API and waited for the next real motion, which came at 21:05:54. Both instances stored that motion with the same second. All 7 helpers were equal on both, but I had copied their starting values and had seen only this one event. That was the first point at which the new instance demonstrably did the same as the old one in real time. It had 11 automations at that point, most of them still in shadow mode.</p><h3>The first full state comparison</h3><p>Covers were the next area, and with 43 covers I had expected a large one. Home Assistant has no logic of its own there either. The covers are driven by KNX push buttons and central functions directly on the bus. I used the step for a comparison of all states between old and new instead, on a Saturday morning after the first night. Counting the entities both instances have in common, 51 of 51 lights had the same state, 11 of 11 thermostats, 43 of 43 covers, 32 of 33 binary sensors and 16 of 29 switches. The two daily energy counters matched to the decimal place. The one differing binary sensor is intended, a contact whose polarity I had corrected at the source on the new instance.</p><p>The comparison uncovered two gaps that exist on both instances. Of the 43 covers, 33 are unknown on both, because the ETS project has no position or end-position feedback assigned for those drives. At that point I assumed this could be fixed in ETS. Reading the actuator programs later showed that for most of these drives it is a hardware limit, which gets its own post in this series. And 25 switches have no status object, so Home Assistant shows the last switching value it saw on the bus, which may no longer be the real state. Where the two instances differ on these, the old one shows off and the new one shows unknown. I consider unknown the more accurate display, since the old off is a remembered value from some earlier time.</p><h3>What must not run twice, and what is open</h3><p>Notifications are a different case from the motion automation. A push at sunrise and sunset or on arriving home would arrive twice if both instances had it enabled, so those automations stay in shadow mode on the new one. Toggle commands are the most dangerous category in parallel operation. A doubled on for a light is harmless, and a doubled toggle produces the opposite of what was wanted, so such automations always get turned off on the old instance first.</p><p>This post does not show a completed switch of an area that acts on the house. Up to the comparison, the old instance was still doing all the switching. The missing feedback for 25 switches and 33 covers was open at that point. Four days later I set the read flag on the objects of the switch actuators in ETS, and since then Home Assistant reads the state of 52 lights and switches on their switching address at start.</p><h3>Frequently asked questions</h3><h3>Can two Home Assistant instances use the same KNX interface?</h3><p>In my setup yes. The new instance got the second tunnel address of the KNX interface and the old one keeps the first. The automatic search needs multicast and found no tunnel from the new instance, so the form went straight to manual entry.</p><h3>Does switching off state polling stop all reads on the KNX bus?</h3><p>No. The integration still reads once at start, which was 52 read requests here. Only sync_state: false on every entity removes those too, and then a state stays unknown until a telegram arrives.</p><h3>Which automations can be active on both instances at once?</h3><p>Only those that write to helpers of their own instance and neither control a device nor send a notification.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-migration-old-and-new-in-parallel"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=29d6f8336ce9" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/home-assistant-migration-old-and-new-in-parallel-29d6f8336ce9">Home Assistant Migration: Old and New in Parallel</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Move KNX Switches From the Home Assistant UI to knx.yaml]]></title>
            <link>https://levelup.gitconnected.com/move-knx-switches-from-the-home-assistant-ui-to-knx-yaml-84acd879186d?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/84acd879186d</guid>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[knx]]></category>
            <category><![CDATA[yaml]]></category>
            <category><![CDATA[smart-home]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Mon, 19 Oct 2026 15:37:15 GMT</pubDate>
            <atom:updated>2026-10-19T15:37:15.353Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*5DsMylPfHxvmPrca" /></figure><p>For about a year I had 33 KNX switches in Home Assistant that existed only in the UI. I had clicked them together in the KNX integration’s entity editor when the lighting on two floors, the garage door and two auxiliary switches were wired up, and it was quick. Home Assistant keeps those switches in a JSON file under .storage, which it rewrites itself and which is not in my Git repository. The thermostats, covers and sensors of the same bus have lived in knx.yaml since the beginning, next to the <a href="https://www.cloudapp.dev/home-assistant-knx-thermostat-standby-after-restart-fix">startup automation</a> and the <a href="https://www.cloudapp.dev/home-assistant-knx-window-open-heating-off">window logic</a>. In September I moved 31 of them over and dropped two that I no longer wanted as switches. I spent a week preparing it, and running it took about ten minutes.</p><p>The KNX integration reads both the UI store and knx.yaml at load time and creates an entity for each definition. A switch defined in both places exists twice, with two entity ids, and nothing warns you. Adding the YAML first and deleting the UI entries afterwards would therefore have created 31 duplicates. I deleted the UI entities first and loaded the YAML after that. In my run the config check of the new file also came after the deletion, as step 2 below shows. A YAML error would have surfaced with the switches already gone, so I would run that check before step 1 next time. Between the deletion and the reload the switches do not exist. During that time they cannot be operated from the dashboard, and automation actions that target them fail.</p><p>Until the migration I kept a deploy variant of knx.yaml on the machine, identical to the repository version but without the block for the 31 switches that were still managed in the UI, and a comment at the top saying so. With that arrangement, deploying the full file by accident would have created every switch twice, which is the second reason I wanted it gone.</p><h3>What a switch looks like in YAML</h3><p>The YAML holds largely the same settings as the UI editor’s JSON file, in a file I can edit and keep in Git. Two details in it were deliberate changes from what I had clicked together a year earlier:</p><pre><br># knx.yaml — two switch definitions, with and without a feedback address<br>switch:<br>  - name: &quot;Light 1&quot;<br>    address: &quot;1/2/3&quot;          # group address the actuator listens on<br>    state_address: &quot;1/3/3&quot;    # feedback object, if the actuator has one<br>    sync_state: true<br>  - name: &quot;Ceiling light&quot;<br>    address: &quot;1/2/4&quot;<br>    # no state_address: the actuator has no feedback object.<br>    # I removed the central status address because reads to it were never answered.</pre><p>Nine switches had a central status address as their state address, an address that the actuator never answered on. Home Assistant queried it, got nothing, and showed whatever state it had last assumed. In the YAML I removed that address from all nine. Two of them now use their real feedback object, and the other seven have no state address, so Home Assistant shows what it last sent for those.</p><h3>The tool: four commands over the websocket API</h3><p>I used a local Node script with four subcommands for this. The commands below are the ones I ran. The script itself and the saved mapping from group address to the old entity id are not part of this post, only the core of the rename step further down. The script uses the same websocket API as the frontend and authenticates with a long-lived access token. Before the first delete I backed up the KNX store, the entity registry and the deployed knx.yaml together. The distinguishing mark between the two kinds of entity is the unique id: UI entities carry an id starting with knx_es_, YAML entities carry the group address itself.</p><pre><br># 0. inventory: 33 UI entities, 2 YAML switches expected<br>node tools/knx_migrate_ui_switches.mjs list<br><br># 1. delete the UI entities from the KNX config store (websocket knx/delete_entity)<br>node tools/knx_migrate_ui_switches.mjs delete-ui<br><br># 2. put the full knx.yaml on the machine and validate it<br>scp knx.yaml ha-host:/tmp/knx.yaml<br>ssh ha-host &#39;sudo cp /tmp/knx.yaml /home/ha/config/knx.yaml \<br>  &amp;&amp; sudo docker exec homeassistant python -m homeassistant --script check_config -c /config&#39;<br><br># 3. reload the integration (no restart), wait, restore the old entity ids<br>node tools/knx_migrate_ui_switches.mjs reload     # calls knx.reload<br>sleep 20<br>node tools/knx_migrate_ui_switches.mjs rename     # sets the previous entity_id per switch<br>node tools/knx_migrate_ui_switches.mjs list       # every line must say OK</pre><p>The delete step uses the knx/delete_entity websocket command per entity, which is what the UI editor calls when you press the trash icon. Home Assistant removes the entity from the KNX store and from the entity registry. That second part is the reason the rename step exists.</p><h3>Keeping the entity ids</h3><p>When the YAML entities come up after the reload, Home Assistant assigns them fresh entity ids derived from their names. Where a new entity id differed from the old one, I would have had to edit every dashboard card and automation that used it. The rename step avoids that: it holds a mapping from group address to the entity id the UI entity used to have, finds the new YAML entity by its unique id, and renames it through the registry API.</p><pre><br>// the core of &quot;rename&quot;: entity_registry/update over the websocket API<br>// MAPPING: group address (write) -&gt; the entity_id the UI entity used to have<br>for (const [ga, oldId] of Object.entries(MAPPING)) {<br>  const yamlEntity = registry.find((e) =&gt; e.platform === &quot;knx&quot; &amp;&amp; e.unique_id === ga);<br>  if (!yamlEntity || yamlEntity.entity_id === oldId) continue;<br>  await ws.send({ type: &quot;config/entity_registry/update&quot;,<br>                  entity_id: yamlEntity.entity_id, new_entity_id: oldId });<br>}</pre><p>I waited 20 seconds between reload and rename. The KNX integration registers its entities asynchronously after the reload call returns, and my script skips any entity it does not find in the registry yet, without an error. I had run list too early once and half the entities were missing. 20 seconds was enough here, and I have not measured how short the wait could be.</p><p>The final list showed 0 UI entities and 33 YAML switches, which are the 31 I carried over plus two that had been in the YAML before. All 31 migrated switches had their previous entity ids again. After the migration all 31 entities were assigned to their previous areas again, and the two labels that a time-control automation filters on were set as before. The rename excerpt above does not show that part.</p><h3>The five automation actions that broke anyway</h3><p>The entity ids were restored, and five actions in three automations still broke. They had been created in the automation editor as device actions, which bind to the registry’s internal device id. Deleting the UI entities deleted those devices, and the actions pointed at nothing. Home Assistant did not fail the three automations for that. It logged a warning when the action ran and skipped it.</p><pre><br># before: a device action bound to a registry device id that no longer exists<br>- device_id: 3f1c…a97e<br>  domain: switch<br>  type: turn_on<br>  entity_id: 9b2d…c410<br><br># after: the same action on the stable entity id<br>- action: switch.turn_on<br>  target:<br>    entity_id: switch.light_1</pre><p>I replaced all five with plain switch.turn_on and switch.turn_off actions on the entity ids and reloaded automations. Actions that had been disabled stayed disabled. Afterwards a full config check and a scan of the log for unknown entity messages came back clean. Since then I write new automation actions as entity actions with switch.turn_on and switch.turn_off, the way the <a href="https://www.cloudapp.dev/splitting-home-assistant-automations-yaml">split YAML files</a> already do.</p><h3>What I tested, and what I could not</h3><p>I switched four switches on and off from Home Assistant, checked the outgoing bus telegrams and restored the original off state afterwards. For the lamps that have no feedback object that is not an independent confirmation, because Home Assistant only knows what it sent. I tested the garage door on site with two pulses and checked its feedback: 0 while opening, 1 after closing. The doc now says that turn_on sends a pulse and does not fix the direction of travel.</p><p>The recorder logged 28 warnings during the renames, because the old target ids already had history and the short history under the interim names is not merged. I left the database alone. I have never executed the rollback, which would restore the KNX store, the entity registry and the old knx.yaml together from the backup I took before the deletion. For such a restore Home Assistant has to be stopped first, otherwise it overwrites the restored .storage files again. I did not want to try that on the production bus for a migration that had already succeeded.</p><h3>Frequently asked questions</h3><h3>Can I keep some KNX entities in the UI and others in YAML?</h3><p>Yes, as long as each switch is defined in only one of the two places. Otherwise it exists twice.</p><h3>Why not just export the UI configuration?</h3><p>There is no export. The store is a JSON file under .storage/knx that you can read, and its structure maps onto the YAML options closely enough that a script can translate it. The tool’s list command is essentially that translation, printed side by side with what the YAML defines.</p><h3>Does knx.reload really avoid a restart?</h3><p>For the KNX integration itself, yes. It tears down and re-creates its entities and re-reads knx.yaml. Automations that reference the entities keep running during that time, so one can trigger while its target entity is missing. A later change to thermostat state addresses went in with a full restart, so I can only vouch for reload on this switch migration.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-knx-ui-switches-to-yaml"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=84acd879186d" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/move-knx-switches-from-the-home-assistant-ui-to-knx-yaml-84acd879186d">Move KNX Switches From the Home Assistant UI to knx.yaml</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[One Day of Home Assistant Logs: 775 Errors, Seven Findings]]></title>
            <link>https://levelup.gitconnected.com/one-day-of-home-assistant-logs-775-errors-seven-findings-6de7add5a18d?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/6de7add5a18d</guid>
            <category><![CDATA[self-hosted]]></category>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[debugging]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[home-assistant]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 15:11:20 GMT</pubDate>
            <atom:updated>2026-10-14T15:11:20.496Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*5Te-IsoCWEO3wn7U" /></figure><p>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.</p><p>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:</p><pre><br># one day of log, counted before reading anything<br>docker logs --since 24h homeassistant 2&gt;&amp;1 | wc -l          # 10490<br>docker logs --since 24h homeassistant 2&gt;&amp;1 | grep -c &#39; ERROR &#39;   # 775<br>docker logs --since 24h homeassistant 2&gt;&amp;1 | grep -c &#39; WARNING &#39; # 5077<br># group by logger name (field 5), most frequent first<br>docker logs --since 24h homeassistant 2&gt;&amp;1 \<br>  | grep -E &#39; (ERROR|WARNING) &#39; \<br>  | awk &#39;{print $5}&#39; | sort | uniq -c | sort -rn | head -20</pre><h3>Ground truth before reading a single error</h3><p>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.</p><p>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.</p><h3>Finding 1: the cameras caused 4,524 of the 5,077 warnings</h3><p>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.</p><p>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.</p><pre><br># before: fires every minute at second 10, mode single,<br># and writes the same detection settings every time<br>triggers:<br>  - trigger: time_pattern<br>    seconds: &quot;10&quot;<br>mode: single<br><br># after: only write when the setting actually differs<br>conditions:<br>  - condition: template<br>    value_template: &gt;<br>      {{ not is_state(&#39;switch.camera_motion_detection&#39;, &#39;on&#39;) }}</pre><h3>Finding 2: a cloud integration that reported loaded and delivered nothing</h3><p>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.</p><p>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 <a href="https://www.cloudapp.dev/home-assistant-huawei-sun2000-modbus-registers">local Modbus registers</a> for a year, and the cloud integration was still set up although nothing used its sensors.</p><h3>Finding 3: 123 errors from a REST sensor that could not handle null</h3><p>The my-PV heating rod exposes its state over a local HTTP endpoint, read by the <a href="https://www.cloudapp.dev/home-assistant-rest-sensor-cloud-api">REST sensor</a> 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:</p><pre><br># rest.yaml, the sensor that failed 110 times in a day<br>- name: &quot;Battery charge&quot;<br>  unit_of_measurement: W<br>  # the API returns null while the device reboots; &quot;None&quot; is not a number<br>  value_template: &gt;<br>    {{ value_json.m2sum | default(0, true) | float(0) }}<br>  availability: &gt;<br>    {{ value_json.m2sum is number }}</pre><p>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.</p><h3>Finding 4: eleven KNX addresses that would never answer</h3><p>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.</p><p>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 <a href="https://www.cloudapp.dev/home-assistant-knx-thermostat-standby-after-restart-fix">thermostats dropped to standby after every restart</a>.</p><h3>Finding 5: 100 unavailable entities, and why that number means little</h3><p>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.</p><p>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.</p><h3>Finding 6: the garage door that was reported open</h3><p>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.</p><p>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.</p><pre><br># before: unavailable counted as &quot;open&quot;<br>- binary_sensor:<br>    - name: &quot;Garage door open&quot;<br>      state: &quot;{{ not is_state(&#39;binary_sensor.garage_contact&#39;, &#39;on&#39;) }}&quot;<br><br># after: the template becomes unavailable together with its source<br>- binary_sensor:<br>    - name: &quot;Garage door open&quot;<br>      state: &quot;{{ not is_state(&#39;binary_sensor.garage_contact&#39;, &#39;on&#39;) }}&quot;<br>      availability: &gt;<br>        {{ states(&#39;binary_sensor.garage_contact&#39;) not in [&#39;unknown&#39;, &#39;unavailable&#39;] }}</pre><h3>Finding 7: what the restart itself surfaced</h3><p>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.</p><p>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 <a href="https://www.cloudapp.dev/home-assistant-shelly-plus-1pm-2pm">Shelly integration</a> 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.</p><h3>Repair order and the restart at the end</h3><p>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.</p><p>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 <a href="https://www.cloudapp.dev/home-assistant-docker-update-rollback">update workflow</a> has me read the log for a few minutes after each update. Between updates I had not been reading it.</p><h3>Frequently asked questions</h3><h3>Is 775 errors a day a lot for Home Assistant?</h3><p>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.</p><h3>Should I restart Home Assistant when the log fills up?</h3><p>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.</p><h3>Why does a template report a state when its source is unavailable?</h3><p>Because not is_state(x, ‘on’) is also true for unknown and unavailable. An availability template that excludes those two source states fixes it.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-log-triage-one-day"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6de7add5a18d" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/one-day-of-home-assistant-logs-775-errors-seven-findings-6de7add5a18d">One Day of Home Assistant Logs: 775 Errors, Seven Findings</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Custom Cards in Home Assistant: Mushroom and button-card]]></title>
            <link>https://levelup.gitconnected.com/custom-cards-in-home-assistant-mushroom-and-button-card-34ee444eba2c?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/34ee444eba2c</guid>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[dashboard]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[javascript]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Wed, 07 Oct 2026 15:29:46 GMT</pubDate>
            <atom:updated>2026-10-07T15:29:46.555Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hdTGLnbQhyWUfkNi" /></figure><p>The stock cards in Home Assistant get you a working dashboard in an afternoon. I wrote that one up in <a href="https://www.cloudapp.dev/home-assistant-dashboard-from-zero-five-steps">five build steps</a> 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.</p><p>Everything here runs on a demo instance on a small Hetzner VM with simulated sensors, the same one the <a href="https://www.cloudapp.dev/home-assistant-themes-six-compared">theme comparison</a> was shot on. The full YAML of the finished dashboard is in the <a href="https://www.cloudapp.dev/home-assistant-dashboard-templates-to-copy">templates post</a>. This post explains the parts of it that are not obvious from reading the file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QZsPB7ffHTaoJvAzPnd7yw.jpeg" /></figure><h3>Four cards, loaded without HACS</h3><p>The four custom cards are <strong>card-mod</strong> (CSS injection into any card), <strong>Mushroom</strong> (the compact tile cards), <strong>mini-graph-card</strong> (the line charts) and <strong>button-card</strong> (a card that renders whatever JavaScript you hand it). On a normal installation you install them through <a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant">HACS</a> 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:</p><pre><br>frontend:<br>  themes: !include_dir_merge_named themes<br>  extra_module_url:<br>    - /local/cards/card-mod.js<br>    - /local/cards/mushroom.js<br>    - /local/cards/mini-graph-card-bundle.js<br>    - /local/cards/button-card.js<br>    - /local/ha-fonts.js</pre><p>The fifth entry is not a card. It is the font loader, and it exists because of the first thing that turned out to be impossible.</p><h3>Themes cannot declare fonts</h3><p>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. In a screenshot that is invisible. In operation it is the difference between a display and a fidget.</p><p>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:</p><pre><br>// www/ha-fonts.js<br>const CSS = `<br>@font-face {<br>  font-family: &#39;Archivo&#39;;<br>  src: url(&#39;/local/fonts/archivo-var.woff2&#39;) format(&#39;woff2-variations&#39;);<br>  font-weight: 100 900;<br>  font-display: swap;<br>}<br>@font-face {<br>  font-family: &#39;IBM Plex Mono&#39;;<br>  src: url(&#39;/local/fonts/plexmono-500.woff2&#39;) format(&#39;woff2&#39;);<br>  font-weight: 500;<br>  font-display: swap;<br>}`;<br>const style = document.createElement(&quot;style&quot;);<br>style.textContent = CSS;<br>document.head.appendChild(style);</pre><p>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. Four downloads, one file, 35 KB. Only the checksums showed it.</p><h3>Mushroom template cards as value tiles</h3><p>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.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nBQbZooFvTtNpUnCAxIjug.jpeg" /></figure><pre><br>- type: custom:mushroom-template-card<br>  primary: Consumption<br>  secondary: &quot;{{ states(&#39;sensor.demo_house_load&#39;) | int }} W&quot;<br>  icon: mdi:home-lightning-bolt<br>  icon_color: grey<br>  layout: vertical<br>  card_mod: &amp;valueblock<br>    style: |<br>      ha-card {<br>        border: 1px solid var(--ha-card-border-color);<br>        padding-bottom: 4px;<br>      }<br>      mushroom-state-info$: |<br>        .primary {<br>          font-family: Archivo, sans-serif !important;<br>          font-size: 0.7rem !important;<br>          letter-spacing: 0.14em;<br>          text-transform: uppercase;<br>        }<br>        .secondary {<br>          font-family: &#39;IBM Plex Mono&#39;, monospace !important;<br>          font-size: 1.15rem !important;<br>          font-variant-numeric: tabular-nums;<br>        }<br><br>- type: custom:mushroom-template-card<br>  primary: Grid import<br>  secondary: &quot;{{ states(&#39;sensor.demo_grid_import&#39;) | int }} W&quot;<br>  icon: mdi:transmission-tower-export<br>  icon_color: &gt;<br>    {{ &#39;red&#39; if states(&#39;sensor.demo_grid_import&#39;) | int &gt; 0 else &#39;grey&#39; }}<br>  layout: vertical<br>  card_mod: *valueblock</pre><p>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.</p><h3>The battery ring is one stroke, not a disc with a hole</h3><p>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.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZaN1B40-aAthVXpCVY5oFA.jpeg" /></figure><p>The version that works draws the ring as an SVG circle and uses stroke-dasharray to show the filled portion. A ring drawn as one stroke has no hole that could go wrong. 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:</p><pre><br>- type: custom:button-card<br>  entity: sensor.demo_battery_soc<br>  show_name: false<br>  show_state: false<br>  show_icon: false<br>  custom_fields:<br>    ring: |<br>      [[[<br>        const soc = Math.max(0, Math.min(100,<br>          Number(states[&#39;sensor.demo_battery_soc&#39;].state) || 0));<br>        const col = soc &gt;= 60 ? &#39;#54cabe&#39; : (soc &gt;= 25 ? &#39;#f0a44a&#39; : &#39;#e0685f&#39;);<br>        const R = 52, C = 2 * Math.PI * R;<br>        const dash = (soc / 100) * C;<br>        return `<br>          &lt;svg class=&quot;ring&quot; viewBox=&quot;0 0 120 120&quot;&gt;<br>            &lt;circle class=&quot;track&quot; cx=&quot;60&quot; cy=&quot;60&quot; r=&quot;${R}&quot;/&gt;<br>            &lt;circle class=&quot;val&quot; cx=&quot;60&quot; cy=&quot;60&quot; r=&quot;${R}&quot;<br>                    stroke=&quot;${col}&quot;<br>                    stroke-dasharray=&quot;${dash.toFixed(1)} ${(C - dash).toFixed(1)}&quot;<br>                    transform=&quot;rotate(-90 60 60)&quot;/&gt;<br>            &lt;text class=&quot;num&quot; x=&quot;60&quot; y=&quot;58&quot;&gt;${soc}&lt;/text&gt;<br>            &lt;text class=&quot;lbl&quot; x=&quot;60&quot; y=&quot;76&quot;&gt;STATE OF CHARGE&lt;/text&gt;<br>          &lt;/svg&gt;`;<br>      ]]]</pre><h3>Two more things that broke</h3><p><strong>Markdown cards as section headings destroyed the grid.</strong> 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.</p><p><strong>The slider stayed neon yellow.</strong> 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, and I found that by reading the computed styles in the running frontend rather than by guessing. Both variables now sit in the theme:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nIt2Y6lWgMjZecypaTZJAw.jpeg" /></figure><pre><br># themes/instrument.yaml (excerpt)<br>instrument:<br>  modes:<br>    dark:<br>      mush-rgb-state-number: &quot;240, 164, 74&quot;<br>      # HA colours the input_number slider via --rgb-state-number,<br>      # Mushroom&#39;s own variable is not enough.<br>      rgb-state-number: &quot;240, 164, 74&quot;<br>    light:<br>      mush-rgb-state-number: &quot;180, 106, 18&quot;<br>      rgb-state-number: &quot;180, 106, 18&quot;</pre><p>A fourth one is a testing problem rather than a design problem. 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.</p><h3>What I would not do again</h3><p>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 real, and the two screenshots in the templates post show it, but the maintenance cost is real too. 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. The value tiles will survive a Mushroom update. The card-mod typography might not.</p><p>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.</p><h3>Frequently asked questions</h3><h3>Do I need HACS to use Mushroom or button-card?</h3><p>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.</p><h3>Why does card-mod need the dollar sign in the selector?</h3><p>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.</p><h3>Does the SVG ring update live?</h3><p>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.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-custom-cards-mushroom-button-card"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=34ee444eba2c" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/custom-cards-in-home-assistant-mushroom-and-button-card-34ee444eba2c">Custom Cards in Home Assistant: Mushroom and button-card</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Splitting a 2,000-Line automations.yaml into 8 Files]]></title>
            <link>https://levelup.gitconnected.com/splitting-a-2-000-line-automations-yaml-into-8-files-bd1a823f886f?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/bd1a823f886f</guid>
            <category><![CDATA[home-automation]]></category>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[yaml]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Fri, 02 Oct 2026 14:43:16 GMT</pubDate>
            <atom:updated>2026-10-02T14:43:16.299Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*gznOaMbbJ97o0o83" /></figure><p>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 was nerve-racking, and finding the one heating automation I wanted meant a full-text search and a prayer. The file worked perfectly — that was the problem. There was no pressure to touch it, and every reason not to, because the moment you refactor a live config you risk silently dropping the automation that keeps the heating from freezing or the alarm from going half-deaf.</p><p>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.</p><h3>Why one file stops scaling</h3><p>Home Assistant wires automations in through one line in <strong>configuration.yaml</strong>: <strong>automation: !include automations.yaml</strong>. That single include is convenient right up until it isn’t. With 63 automations in there, a few patterns had emerged on their own. 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.</p><p>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.</p><h3>A 40-line splitter that classifies by name</h3><p>The splitter is deliberately boring. It’s about 40 lines of pure standard-library Python — just <strong>yaml</strong>, <strong>os</strong>, and a <strong>defaultdict</strong>. It loads the YAML list, walks each automation, decides which bucket it belongs to, and dumps one file per bucket into an <strong>automations/</strong> directory.</p><p>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 <strong>alias</strong>. 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. It’s string matching, not metadata, and I’d rather say that plainly than dress it up as something smarter.</p><pre><br>categories = defaultdict(list)<br>for automation in automations:<br>    alias = automation.get(&#39;alias&#39;, &#39;Unknown&#39;)<br>    if &#39;Heizung&#39; in alias:<br>        categories[&#39;heating&#39;].append(automation)<br>    elif &#39;Alarm&#39; in alias:<br>        categories[&#39;alarm&#39;].append(automation)<br>    elif &#39;AC Thor&#39; in alias or &#39;AC-Thor&#39; in alias:<br>        categories[&#39;ac_thor&#39;].append(automation)<br>    # ... cameras / notifications / time_control / system / misc<br>    else:<br>        categories[&#39;misc&#39;].append(automation)<br><br>for category, autos in categories.items():<br>    with open(f&#39;automations/{category}.yaml&#39;, &#39;w&#39;) as f:<br>        yaml.dump(autos, f, default_flow_style=False,<br>                  allow_unicode=True, sort_keys=False)</pre><p>Two of those dump arguments matter more than they look. <strong>allow_unicode=True</strong> keeps the German umlauts intact instead of mangling them into escape sequences, so a name like “Büro” survives the round trip. And <strong>sort_keys=False</strong> 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.</p><h3>Eight buckets the data chose for itself</h3><p>I didn’t pick eight up front — I’d half-expected to hand-carve a dozen or so files by feel. The committed splitter emits eight category files: heating, alarm, ac_thor, cameras, notifications, time_control, system, and misc. Eight buckets is what the data wanted, not a number I imposed. The distribution is lopsided in a way that’s genuinely useful to see once it’s broken out: 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.</p><p>That imbalance is the real finding. A 700-line heating file controlling 11 thermostats with near-identical day/night/presence logic is begging to be templated into a handful of parameterised scripts — that’s the obvious next refactor, and now that it’s isolated I can actually see the shape of it. 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.</p><h3>The validator is the actual hero</h3><p>Splitting a live config is only as trustworthy as your proof that nothing fell out. So before I changed a single line of <strong>configuration.yaml</strong>, 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 <strong>id</strong> value matches as a set. Five: a per-alias deep dictionary comparison to catch silent content drift.</p><pre><br>if original_count == split_count:<br>    print(f&quot;COUNT CHECK: {split_count} automations (matches original)&quot;)<br><br>missing_in_split = set(original_aliases) - set(split_aliases)<br>extra_in_split   = set(split_aliases) - set(original_aliases)<br># IDs compared as sets; per-alias deep compare flags content drift<br>for alias in original_alias_set:<br>    if alias in split_by_alias and original_by_alias[alias] != split_by_alias[alias]:<br>        content_mismatches.append(alias)</pre><p>The detail I’m proudest of is the deliberate split between errors and 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. That’s a judgement call I made 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. So a lost automation aborts the whole thing; a cosmetic re-quote just gets a note.</p><h3>The one-line payoff</h3><p>With the validator green, the cutover is almost anticlimactic. You swap one line in <strong>configuration.yaml</strong> — <strong>!include automations.yaml</strong> becomes <strong>!include_dir_merge_list automations/</strong> — and Home Assistant now merges every file in that directory into one automation list, exactly as if they were still concatenated.</p><p>Then you reload without a full restart: a <strong>ha core check</strong> to validate the config first, then a call to the <strong>homeassistant.reload</strong> service against the automation entity — for me that’s a small POST to the REST API at something like <a href="http://homeassistant.local:8123,">http://homeassistant.local:8123,</a> 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.</p><p>If you take one habit from this, make it a naming convention: prefix new aliases with their category, like “Heating: &lt;room&gt; night mode”, so the substring classifier stays deterministic as the config grows. The splitter only stays honest if the names do. The rest of how this house runs on Home Assistant — <a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm">from the Docker-on-Azure install</a> on up — is built on exactly this kind of small, defensible step, and the next one is finally templating that 700-line heating file.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/splitting-home-assistant-automations-yaml"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bd1a823f886f" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/splitting-a-2-000-line-automations-yaml-into-8-files-bd1a823f886f">Splitting a 2,000-Line automations.yaml into 8 Files</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Home Assistant Dashboard From Zero]]></title>
            <link>https://levelup.gitconnected.com/building-a-home-assistant-dashboard-from-zero-2088482936eb?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/2088482936eb</guid>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[dashboard]]></category>
            <category><![CDATA[ui-design]]></category>
            <category><![CDATA[home-automation]]></category>
            <category><![CDATA[home-assistant]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 15:12:18 GMT</pubDate>
            <atom:updated>2026-09-30T15:12:18.022Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*45XpSmHf0amYqdvI" /></figure><p>Most Home Assistant dashboard guides show you a finished dashboard. That is the least useful picture, because the interesting part is what happens between an empty view and that screenshot, and it is where people give up.</p><p>So I built one in five steps and kept every step as its own file. Same instance, same data, same screen size, nothing else changed between the images. What follows is those five states in order.</p><h3>Step one: the empty view</h3><p>A new dashboard is a headline and a lot of nothing. No guide shows this, and it is worth seeing once, because the panic of not knowing where to start is a real reason dashboards stay bad.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0Uh2ZQUi441bcs7hEcThVg.jpeg" /><figcaption>Step one: nothing at all.</figcaption></figure><h3>Step two: the first card</h3><p>One tile. It occupies a single column and leaves the rest of the width empty, which looks broken and is not — the grid only fills out once there are enough cards to fill it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WNjU1WD_wxM-eQdkz-qnmg.jpeg" /><figcaption>Step two: one card, and a lot of empty grid.</figcaption></figure><h3>Step three: five cards, no order</h3><p>This works. Every value is on screen, everything is readable, and you could stop here. Most dashboards do stop here, and it is why so many of them look like a list of numbers rather than something you glance at.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JBaq88qzvWmpdS85oUrQqg.jpeg" /><figcaption>Step three: five cards. Everything readable, nothing organised.</figcaption></figure><h3>Step four: the same cards, grouped</h3><p>Thirteen cards now, in three groups with headings. This is the step the whole post exists for. Between the third image and the fourth, <strong>no new content is added</strong>. The same values are on screen. What changed is that they are grouped and labelled, and the difference in how quickly you can read it is larger than anything I did later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eCbgEKQFUK0x-tx99uMXtw.jpeg" /><figcaption>Step four: the same values, in three labelled groups. Compare this with the image above.</figcaption></figure><p>If you take one thing from this: the fix for a dashboard that feels cluttered is almost never another card. It is putting the cards you have into sections that say what they are.</p><h3>Step five: the finished view</h3><p>Twenty-three cards, with gauges, a twelve-hour history graph and the setpoints that are actually adjusted. This is the version I kept, and it is only two steps of work past the one that looked like a list.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qr2PmFYR2tWNDIYBtTQIfQ.jpeg" /><figcaption>Step five: 23 cards, gauges and a history graph.</figcaption></figure><h3>What step four looks like as configuration</h3><p>The grouping is a view of type <strong>sections</strong>, and each group is a grid with a heading card as its first entry. Two settings do most of the work: <strong>max_columns</strong> caps how wide the whole view spreads on a large screen, and <strong>column_span</strong> lets one section take more width than its neighbours.</p><pre><br>views:<br>  - title: Übersicht<br>    type: sections<br>    max_columns: 3<br>    sections:<br>      - type: grid<br>        column_span: 2<br>        cards:<br>          - type: heading<br>            heading: Energie<br>            heading_style: title<br>            icon: mdi:solar-power-variant<br>          - type: gauge<br>            entity: sensor.pv_leistung<br>            name: Erzeugung<br>            min: 0<br>            max: 8000<br>            needle: true</pre><p>On a 27-inch screen a view without <strong>max_columns</strong> spreads to five or six columns and stops being readable at a glance, which was not obvious to me until I looked at it on a monitor rather than a laptop. Three is the number I kept.</p><h3>Three things that broke on the way</h3><p><strong>Markdown cards used as headings wrecked the layout.</strong> They count as a grid cell, so the tiles below them redistributed into the wrong columns and the whole section looked misaligned. The native <strong>heading</strong> card spans the full section width instead and fixes it.</p><p><strong>Numbers jumped every time they changed.</strong> Proportional digits have different widths, so a value ticking from 1.111 to 1.222 shifts the whole line. Switching the number font to one with tabular figures stops it. You cannot see this in a screenshot, and you cannot unsee it once the dashboard is running.</p><p><strong>A battery ring rendered as a full disc.</strong> I built it as a conic gradient with a hole in the middle whose background was set to a Home Assistant CSS variable. That variable does not resolve inside the shadow DOM of the card I used, so the hole stayed transparent and the gradient showed through. The fix was to stop cutting a hole in a disc and draw a circle with a dashed stroke instead. The card in question is button-card, which is not part of Home Assistant — it comes from HACS, and <a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant">installing that first</a> is the step people skip before wondering why a template does nothing.</p><h3>Why the files, not the editor</h3><p>Everything above is a YAML dashboard, wired in through the configuration rather than clicked together in the interface. The reason is not purity. A dashboard defined by a file has a state you can reproduce: five files gave me five identical framings for the five images, and doing that by hand across five screenshots would not have held.</p><p>The editor is faster for a first draft. The file is what lets you keep versions of a layout and go back to one. If you want the finished thing rather than the walk-through, I put <a href="https://www.cloudapp.dev/home-assistant-dashboard-examples-built-in-cards">two dashboards built from stock cards</a> in a separate post, and the <a href="https://www.cloudapp.dev/hacs/cards">card directory</a> lists what is available once you go past the built-in ones.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-dashboard-from-zero-five-steps"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2088482936eb" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/building-a-home-assistant-dashboard-from-zero-2088482936eb">Building a Home Assistant Dashboard From Zero</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Home Assistant Thermostat on KNX: Base vs. Active Setpoint]]></title>
            <link>https://levelup.gitconnected.com/knx-thermostat-preset-modes-in-home-assistant-d3c04f811005?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/d3c04f811005</guid>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[heating]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[knx]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Mon, 28 Sep 2026 19:15:09 GMT</pubDate>
            <atom:updated>2026-10-24T09:22:38.520Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*l2DzIXB53fhbUCL1.jpg" /></figure><p><strong>Update, October 2026.</strong> The first version of this post, from June, explained a symptom on my KNX room controllers with a cause I had never checked. It said that only the Auto preset lets you set a target temperature from Home Assistant, that the Comfort and Economy setpoints are fixed in the device, and that the controller overrules whatever you write. The tests on 23 October showed that writing the base changed the reported setpoint without any mode command before it. I could not read the controller’s actual mode during the tests, and I never tested the advice to switch to Auto first.</p><p>On the standard Home Assistant thermostat card I set a temperature, and a moment later the card showed a different value, in my tests one or two degrees lower. My writes reached the controller while Home Assistant showed Comfort; the controller’s actual mode I could not read from the device. The value the card shows afterwards is a different one, reported by the controller. This post covers the tests from 23 October and the script and dashboard card I built for the 11 room controllers in the house.</p><h3>The five HVAC modes on a KNX climate entity</h3><p>The controllers take the standard KNX HVAC mode (DPT 20.102) with five states: Auto, Comfort, Standby, Economy and Building Protection. Home Assistant shows them as preset modes. My automations use Comfort for the day, Economy for the night and Building Protection while a window is open. According to the controller documentation, Standby is a setback mode. The earlier version of this post treated it as heating off, which the documentation does not support.</p><p>The card cannot tell you which mode the controller is really in. In the ETS project my controllers have no readable status object for the operating mode. Home Assistant sends mode changes to the command object. After a restart, reading that object returns Standby even if Home Assistant had sent Comfort before, and the controller’s actual mode cannot be checked through it. The preset shown is only reliable when Home Assistant wrote it itself, which is the background of the <a href="https://www.cloudapp.dev/home-assistant-knx-thermostat-standby-after-restart-fix">Standby-after-restart post</a>.</p><h3>Two values behind one Home Assistant thermostat card</h3><p>According to the manufacturer documentation and the ETS export, each of the 11 JUNG controllers has a writable input for the <strong>comfort base temperature</strong> and a readable output for the <strong>current setpoint</strong>. Home Assistant uses the first as target temperature address and the second as target temperature state address. The controller adds the operating mode and any local shift at the device on top of the base. The climate entities live in knx.yaml, like the switches since I <a href="https://www.cloudapp.dev/home-assistant-knx-ui-switches-to-yaml">moved them out of the UI</a>. The excerpt shows them under the knx: key, as in a package file; with knx: !include knx.yaml that outer key is left out. The new sensor is the second block:</p><pre># knx.yaml — one room controller (group addresses are placeholders)<br>knx:<br>  climate:<br>    - name: &quot;Thermostat Room A&quot;<br>      temperature_address: &quot;9/0/3&quot;               # room temperature (read)<br>      target_temperature_address: &quot;9/1/3&quot;        # comfort base temperature (write)<br>      target_temperature_state_address: &quot;9/2/3&quot;  # active setpoint the controller reports<br>      operation_mode_address: &quot;9/3/3&quot;            # HVAC mode command, no readable status<br>      temperature_step: 0.5<br>      min_temp: 18.0<br>      max_temp: 25.0<br>  sensor:<br>    # separate read-only sensor on the same active-setpoint address<br>    - name: &quot;RC setpoint Room A&quot;<br>      state_address: &quot;9/2/3&quot;<br>      type: temperature<br>      sync_state: init<br>      always_callback: true   # every answer updates last_updated, even with an unchanged value</pre><p>Reading the base back does not work here. The comfort input was not meant to be read, its read flag was added in the ETS later, and the answers decode to values like 0.21 °C, for reasons I have not found. The only reliable feedback is the active setpoint.</p><h3>What the tests on 23 October showed</h3><p>On the controller I tested I wrote a base of 23 °C, without any mode command. Home Assistant showed Comfort, which as described above is no confirmation from the device. The controller reported 21 °C, on its own and on a fresh read request. A base of 25 °C gave 23 °C, which looked like a fixed difference of −2 K.</p><p>Then I raised the setpoint by one degree at the controller itself, and it reported 24 °C. From the Home Assistant dialog I lowered it to 23 °C, Home Assistant wrote a base of 23 °C, and the controller reported 22 °C. The difference was now −1 K, so an automation that always adds 2 K would have been wrong after one button press on the wall. Whether the original −2 K came from the operating mode or a local shift I do not know.</p><p>Half degrees did not arrive exactly. Aiming for 23.5 °C, a base of 23.5 gave 23, a base of 24 gave 23, and a base of 24.5 gave 24. I tested this on one controller only and limited the new control to whole degrees for all of them.</p><p>I did not switch any controller to Auto during these tests and have not tested what Auto does to the reported setpoint. The documentation also describes writing the comfort base in Comfort mode; in my test, writing worked without first sending a mode command, though I could not verify the controller’s mode.</p><h3>The fix: a read-back sensor and a correcting script</h3><p>Each controller now has its own read-only KNX sensor on the active-setpoint address. With always_callback set, every answer updates the sensor’s last_updated timestamp, even when the value is unchanged, so the script can tell a fresh answer from a value Home Assistant already had.</p><p>The script takes a room and a wanted temperature in whole degrees from 18 to 25 °C. It requests a fresh value and waits up to 5 seconds. If the controller already reports the wanted value, it stops without writing. Otherwise it writes a base, waits one second and reads again. For the next attempt, the script adds the difference between the wanted and the reported value to the last base it wrote; it determines that difference again on every call. After three writes, or when the next base would leave 18–25 °C, it stops with an error and the last reported value. It reports success only after the controller has returned the wanted setpoint twice, one second apart. The excerpt below shows only the correction loop; the room mapping, the variable setup and the separate read script are left out.</p><pre># packages/raumcontroller.yaml — shortened<br>script:<br>  rc_set_temperature:<br>    mode: single                 # one job at a time, no competing correction loops<br>    fields:<br>      entity_id: {}              # climate entity of the room<br>      temperature: {}            # wanted active setpoint, whole degrees 18–25<br>    sequence:<br>      # fresh read: knx.read, then wait up to 5 s for a newer last_updated<br>      - &amp;read_fresh<br>        action: script.rc_read_setpoint<br>        data: { address: &quot;{{ room.address }}&quot;, sensor: &quot;{{ room.sensor }}&quot; }<br>        response_variable: reading<br>      # (already right -&gt; report success without writing; omitted here)<br>      - repeat:<br>          count: 3<br>          sequence:<br>            - if: &quot;{{ candidate &lt; 18 or candidate &gt; 25 }}&quot;<br>              then:<br>                - stop: &quot;Not reachable within the base limits 18–25 °C.&quot;<br>                  response_variable: result   # ok: false plus the last reported setpoint<br>            - action: climate.set_temperature   # writes the comfort base<br>              target: { entity_id: &quot;{{ entity_id }}&quot; }<br>              data: { temperature: &quot;{{ candidate }}&quot; }<br>            - delay: { seconds: 1 }<br>            - *read_fresh<br>            - variables:<br>                actual: &quot;{{ reading.actual }}&quot;   # use the new reading for the next step<br>            # (no fresh answer -&gt; error result; omitted)<br>            # (match -&gt; wait 1 s, read again, success only on a second match; omitted)<br>            - variables:<br>                candidate: &quot;{{ (candidate + wanted - actual) | round(2) }}&quot;</pre><p>The script runs in single mode, and it does not hold the temperature afterwards: later changes at the controller or by the schedule apply as usual. With the measured difference of −1 K, an active setpoint of 25 °C would need a base of 26 °C, which Home Assistant is not allowed to write, so the card shows an error. Any other climate.set_temperature call, from another dashboard or a voice assistant, still writes the base directly and skips all of this, so automations should call the script.</p><h3>The dashboard card and the tests</h3><p>All 11 standard thermostat tiles on my main dashboard are replaced by a small custom card with the room temperature, the active setpoint from the new sensor, and a field with plus and minus buttons in whole degrees. An empty field starts from the last reported setpoint. Nothing is sent until Apply starts the script. During a job the controls are locked, at 18 and 25 °C the respective button is disabled, and the result stays on the card, errors included.</p><p>Eleven automated tests run the script’s actual YAML sequences against a simulated bus: positive, negative and changing differences, a value that is already right, missing answers at each step, the base limits, too many attempts and invalid input. Browser tests cover the card. On the real controller I requested 24 °C and read back 24 °C after a base of 25 °C; the next request for 23 °C read back 23 °C at a base of 24 °C.</p><h3>Automations that switch presets</h3><p>The heating automations never write a temperature, only the preset, so the setpoint problem did not affect them. A helper called heating night mode goes on at 21:00 and off at 05:00, which sets all 11 controllers to Economy or Comfort. The earlier version said 06:00 because the old automation’s entity id says so, though it has always run at 05:00. Night was Standby in one place and Economy in two. On the new instance I set all three to Economy and made the helper keep its state across a restart.</p><p>The window logic covers eight rooms. After a window has been open for 5 seconds, that room goes to Building Protection, and 5 seconds after closing it returns to the mode that currently applies. On the old instance this was 16 copied UI automations, and closing always set Comfort, also at night. On the new instance it is one automation with a table from window to controller, and the day and night automations skip rooms with an open window. More in the <a href="https://www.cloudapp.dev/home-assistant-knx-window-open-heating-off">window post</a>.</p><p>Without a readable mode status, sending the mode again after a start is the only way to a known state. The startup automation waits 30 seconds, then sets the night or day mode, again skipping open windows. On the old instance it has been disabled since January. The new heating automations described in this section are set up on the new instance but stay disabled until I move heating over; until then the old instance runs the schedule and the window logic. The setpoint sensors, the script and the card are already in use on the new instance. Whether I keep that day-or-night choice at startup when I move heating over is not decided yet.</p><h3>Frequently asked questions</h3><h4>Why does my KNX thermostat show a different temperature than I set in Home Assistant?</h4><p>On controllers like mine, Home Assistant sends the comfort base and then displays the setpoint the controller reports, which includes the operating mode and any local shift. The standard card has room for one target value only.</p><h4>Do I have to switch to Auto before changing the temperature?</h4><p>My writes reached the JUNG controller without switching modes first. I have not tested Auto itself or its effect on the reported setpoint.</p><h4>Can I compensate the difference with a fixed offset?</h4><p>Not reliably. The difference I measured went from −2 K to −1 K after one local adjustment, which is why the script recalculates it from fresh read-backs on every call and gives up after three attempts.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-knx-thermostat-preset-modes"><em>cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d3c04f811005" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/knx-thermostat-preset-modes-in-home-assistant-d3c04f811005">Home Assistant Thermostat on KNX: Base vs. Active Setpoint</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[KNX Motion-Sensor Automations in Home Assistant]]></title>
            <link>https://levelup.gitconnected.com/knx-motion-sensor-automations-in-home-assistant-7cb090938aeb?source=rss-045c5a6090e3------2</link>
            <guid isPermaLink="false">https://medium.com/p/7cb090938aeb</guid>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[knx]]></category>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[motion-sensor]]></category>
            <category><![CDATA[home-assistant]]></category>
            <dc:creator><![CDATA[Cloudapp-dev]]></dc:creator>
            <pubDate>Fri, 25 Sep 2026 15:05:04 GMT</pubDate>
            <atom:updated>2026-09-25T15:05:04.520Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*UF69kGiFOAuYGbBe" /></figure><p>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 <strong>smart</strong>, 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.</p><p>This is Part 05 of the series. The earlier parts cover the boring-but-load-bearing groundwork: <a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm">running Home Assistant in Docker</a> and <a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant">wiring up HACS</a>. Here I’m assuming HA is up, talking KNX, and you just want the lights to behave.</p><h3>One sensor, two jobs, two addresses</h3><p>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 <strong>binary_sensor</strong> with <strong>device_class: motion</strong>, fed by a KNX group-address state object you configure in <strong>knx.yaml</strong> with a <strong>state_address</strong> 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.</p><p>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.</p><p>The fix isn’t in Home Assistant at all. It’s in ETS: give each physical PIR a <strong>second group address</strong>. One drives comfort lighting, the other feeds presence and the alarm path. I use a flat convention — <strong>5/4/x</strong> for lighting, <strong>5/5/x</strong> for the security path, <strong>5/6/x</strong> 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.</p><h3>The dumb version first</h3><p>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:</p><pre><br># Motion ON -&gt; light on; motion OFF for 5 min -&gt; light off<br>- alias: &quot;Hallway light on motion&quot;<br>  triggers:<br>    - trigger: state<br>      entity_id: binary_sensor.flur_motion<br>      from: &quot;off&quot;<br>      to: &quot;on&quot;<br>  actions:<br>    - action: light.turn_on<br>      target: { entity_id: light.flur }<br>      data: { brightness_pct: 100 }<br>  mode: single<br><br>- alias: &quot;Hallway light off after 5 min&quot;<br>  triggers:<br>    - trigger: state<br>      entity_id: binary_sensor.flur_motion<br>      from: &quot;on&quot;<br>      to: &quot;off&quot;<br>      for: { minutes: 5 }<br>  actions:<br>    - action: light.turn_off<br>      target: { entity_id: light.flur }<br>  mode: single</pre><p>Two things are doing quiet work here. <strong>mode: single</strong> keeps the automation from re-triggering itself into a storm when a sensor chatters — pair it with <strong>max_exceeded: silent</strong> so the overlap is swallowed without filling your log. And the off side triggers on the sensor going to off with a <strong>for:</strong> of five minutes, not immediately. That <strong>for:</strong> is the single most important number in the whole setup.</p><h3>The honest failure: lights dropping while you’re still there</h3><p>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.</p><p>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.</p><p>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.</p><h3>The touches that make it feel intentional</h3><p>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.</p><p><strong>Night-only stairs.</strong> The stairwell and hallway lights only auto-fire in the dark. A <strong>condition: sun</strong> with after sunset / before sunrise gates them, so they don’t pointlessly switch on at noon when there’s plenty of daylight.</p><p><strong>Time-of-day brightness.</strong> A fixed brightness is a tell that a machine wrote your house. Full blast at 3am is hostile. I drive brightness with a <strong>choose:</strong> 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:</p><pre><br># Time-of-day brightness with choose:<br>actions:<br>  - choose:<br>      - conditions:<br>          - condition: time<br>            after: &quot;22:00:00&quot;<br>            before: &quot;06:00:00&quot;<br>        sequence:<br>          - action: light.turn_on<br>            target: { entity_id: light.stairs }<br>            data: { brightness_pct: 30 }<br>    default:<br>      - action: light.turn_on<br>        target: { entity_id: light.stairs }<br>        data: { brightness_pct: 100 }</pre><p><strong>Pathway lighting up the stairs.</strong> My favourite trick. The stair automation chains two lights with a <strong>delay:</strong> 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.</p><p><strong>A whole-zone energy cutoff.</strong> 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 <strong>area_id</strong> rather than enumerating every fixture means one automation can switch off a whole zone at once.</p><h3>A cheap presence value, almost for free</h3><p>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 <strong>input_datetime</strong> 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.</p><p>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.</p><p>To tune any of this, put the sensors on a Lovelace <strong>type: entities</strong> card so you can watch live motion state per area while you fiddle with the off-delays. Seeing a sensor sit on <strong>off</strong> a full second before you expected it to is how you learn which room needs a longer <strong>for:</strong>.</p><h3>What I’d tell past me</h3><p>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.</p><p>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.</p><p>The other half of “how long has this room been empty” is <a href="https://www.cloudapp.dev/home-assistant-last-motion-sensor-input-datetime">tracking last-motion in an input_datetime helper</a> — a small pattern that pairs well with the KNX group addresses above.</p><p><em>Originally published at </em><a href="https://www.cloudapp.dev/home-assistant-knx-motion-sensor-automations"><em>https://www.cloudapp.dev</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7cb090938aeb" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/knx-motion-sensor-automations-in-home-assistant-7cb090938aeb">KNX Motion-Sensor Automations in Home Assistant</a> was originally published in <a href="https://levelup.gitconnected.com">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>