<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Bernhart</title>
    <description>The latest articles on DEV Community by Michael Bernhart (@cloudapp_dev).</description>
    <link>https://dev.to/cloudapp_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3974608%2Fc22d5e7e-7665-4ecb-b10b-4d4bb29e2c04.png</url>
      <title>DEV Community: Michael Bernhart</title>
      <link>https://dev.to/cloudapp_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudapp_dev"/>
    <language>en</language>
    <item>
      <title>Tapo H100: Cellar Humidity Monitoring in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 07 Aug 2026 06:21:39 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/tapo-h100-cellar-humidity-monitoring-in-home-assistant-i39</link>
      <guid>https://dev.to/cloudapp_dev/tapo-h100-cellar-humidity-monitoring-in-home-assistant-i39</guid>
      <description>&lt;p&gt;Cellars sweat. On a warm humid day the air you let in is warmer than the cold concrete, and the moment it touches a cold surface it gives up its water. That's condensation, and over enough summers it's how a basement grows mould in the corners you never look at. I wanted a number that warned me before that happened. The catch I already knew going in: a raw relative-humidity reading isn't that number.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
    <item>
      <title>Updating Home Assistant in Docker Safely: My Workflow With Backup, Test and Rollback</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 28 Jul 2026 08:34:57 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/updating-home-assistant-in-docker-safely-my-workflow-with-backup-test-and-rollback-3dd3</link>
      <guid>https://dev.to/cloudapp_dev/updating-home-assistant-in-docker-safely-my-workflow-with-backup-test-and-rollback-3dd3</guid>
      <description>&lt;p&gt;My Home Assistant has run for years as a bare Docker container — no Supervisor, no HAOS, just the official image and a mount onto my config directory. It's lean and controllable, but it comes at a price: there is no "update" button in the UI and no automatic snapshot. A major update can break my KNX heating, the PV sensors and half a dozen HACS integrations all at once, and if I'm unlucky I only find out when the bathroom stays cold that evening.&lt;/p&gt;

&lt;p&gt;After a few painful updates I settled on a fixed procedure that I run through stubbornly every single time. The core of it: order is everything, the container gets recreated instead of overwritten in place, and I note down the exact way back before I touch anything. Here is the complete flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order trips almost everyone up
&lt;/h2&gt;

&lt;p&gt;The most common mistake is to pull the Home Assistant core first and then wonder why HACS integrations are broken. It is actually safer the other way around: &lt;strong&gt;update the custom components and HACS first and test them against the still-running OLD core version, then raise the core.&lt;/strong&gt; That cleanly separates the two sources of failure. If something breaks after the HACS update, you know it's the integration — not a core break you rolled in at the same time.&lt;/p&gt;

&lt;p&gt;Concretely: I open HACS, update the pending custom components one by one (for me usually things like the Shelly or Huawei Solar integration), restart Home Assistant once — still on the old core — and check that everything works. Only when that intermediate state is clean do I go for the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — full backup before touching anything
&lt;/h2&gt;

&lt;p&gt;Before every update I back up the entire config directory. On a Docker install that's simply the folder you mount into the container — it holds &lt;strong&gt;configuration.yaml&lt;/strong&gt;, the database, the &lt;strong&gt;.storage&lt;/strong&gt; registries and all your YAML packages. If that folder is backed up, your entire state is backed up. Put the copy outside the container mount (a different directory or another host), not next to it.&lt;/p&gt;

&lt;p&gt;Important: stop the container before the backup, or at least accept the live SQLite database. For a major update with a database migration, a clean snapshot of the old state is the only thing the migration won't carry along anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — note the old image ID (your real rollback)
&lt;/h2&gt;

&lt;p&gt;This is the step almost every guide leaves out, and it's the most important one. "Rollback" in Docker does not mean "pull stable again" — that just fetches the new version. A real way back only works via the exact image ID your container ran on &lt;strong&gt;before&lt;/strong&gt; the update. You record it before pulling anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;sudo docker inspect homeassistant --format '{{.Image}}'&lt;/span&gt;
&lt;span class="c1"&gt;# Rollback: docker run ... &amp;lt;OLD_IMAGE_ID&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save that ID somewhere (text file, note, whatever). As long as the old image hasn't been pruned locally, you can start a new container with &lt;strong&gt;&lt;/strong&gt; instead of the &lt;strong&gt;stable&lt;/strong&gt; tag at any time and be back on the old state within seconds — together with your (backed-up) old config folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — recreate the container instead of in-place
&lt;/h2&gt;

&lt;p&gt;Now the actual core update. I &lt;strong&gt;never&lt;/strong&gt; update in place; I stop the container, pull the new image and recreate the container from scratch — with exactly the same mount and the same flags as before. That keeps the container state predictable and stops any old runtime configuration from dragging along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;sudo docker stop homeassistant&lt;/span&gt;
&lt;span class="s"&gt;sudo docker pull ghcr.io/home-assistant/home-assistant:stable&lt;/span&gt;
&lt;span class="s"&gt;sudo docker rm homeassistant &amp;amp;&amp;amp; sudo docker run -d \&lt;/span&gt;
  &lt;span class="s"&gt;--name homeassistant --restart unless-stopped --network host \&lt;/span&gt;
  &lt;span class="s"&gt;-v /home/USER/homeassistant:/config \&lt;/span&gt;
  &lt;span class="s"&gt;ghcr.io/home-assistant/home-assistant:stable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;strong&gt;/home/USER/homeassistant&lt;/strong&gt; with your actual config path. The &lt;strong&gt;--network host&lt;/strong&gt; mode is practically mandatory for many local integrations (discovery, Modbus, KNX); keep exactly the flags your old container had — a forgotten flag is its own class of "X stopped working after the update" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — wait for the database migration
&lt;/h2&gt;

&lt;p&gt;On a major update, Home Assistant migrates its database schema on first start. Depending on database size that can take several minutes, and during that time the API won't answer yet. Don't get impatient and restart the container — that aborts the migration mid-flight. Instead I watch for when the API comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;watch -n 5 'curl -s -m 3 http://HA_HOST:8123/api/ 2&amp;gt;/dev/null'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;strong&gt;HA_HOST&lt;/strong&gt; with your instance's address. As soon as the command returns a JSON response instead of an empty line, the core is up and the migration is done. In parallel it's worth tailing the container log (&lt;strong&gt;docker logs -f homeassistant&lt;/strong&gt;), which logs the migration as it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — the post-update checklist
&lt;/h2&gt;

&lt;p&gt;A green API endpoint doesn't yet mean the smart home works. I keep a short checklist of the things that, in my experience, act up first after updates — especially anything that goes over local bus systems. I actively walk through it after every update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;KNX thermostats respond (switch preset mode)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;Window sensors correct (all off after startup)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;Solar/PV data coming in&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;HACS works&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s"&gt;Automations active (blind schedule, heating day/night)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I check the PV and Modbus sensors especially closely, because they're prone to being mapped differently after a core jump — how I wire them up in the first place is in the &lt;a href="https://www.cloudapp.dev/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;Modbus basics post&lt;/a&gt;. If something's off, I fall back to the rollback from step 2 instead of poking around in a live system.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the update goes wrong: the rollback in practice
&lt;/h2&gt;

&lt;p&gt;The way back is exactly the reverse of step 3, just with the old image ID. Stop and remove the container, restore the old config folder from the backup (important if the migration already touched the schema — a migrated database won't run reliably on the old core), and start a new container with &lt;strong&gt;&lt;/strong&gt;. That's precisely why the backup and the noted image ID aren't an optional extra but the two things that separate a bad update evening from a completely wrecked weekend.&lt;/p&gt;

&lt;p&gt;My self-sufficient energy setup, for example, hangs off a chain of Modbus sensors and template calculations — if the self-consumption ratio suddenly reads &lt;strong&gt;unavailable&lt;/strong&gt; after the update, I know a data source has dropped out, and I decide from the checklist whether to fix forward or roll back. How those derived metrics are built is described in the &lt;a href="https://www.cloudapp.dev/home-assistant-pv-self-consumption-autarky-sensors" rel="noopener noreferrer"&gt;autarky sensors post&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why not just use Watchtower for automatic updates?
&lt;/h3&gt;

&lt;p&gt;Watchtower silently pulls the newest image and recreates the container — which is exactly the scenario I want to avoid. A major update with a database migration and potentially breaking HACS integrations is something I want to run in a controlled way, with a backup and a noted rollback ID, not automatically at three in the morning. For pure minor/patch releases Watchtower is tempting, but Home Assistant's &lt;strong&gt;stable&lt;/strong&gt; tag doesn't distinguish patch from major — so I deliberately do it by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isn't the Home Assistant backup enough instead of a folder backup?
&lt;/h3&gt;

&lt;p&gt;The built-in backup doesn't exist as a button in the pure Docker variant (without Supervisor) — it's a Supervisor feature. In a bare container, your config mount is the backup. That's exactly what makes it so simple: copying one directory secures your complete state including the database and &lt;strong&gt;.storage&lt;/strong&gt; registries.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long do I keep the old image?
&lt;/h3&gt;

&lt;p&gt;Until the new update has proven itself over several days of normal operation. Only then do I clean up with &lt;strong&gt;docker image prune&lt;/strong&gt;. Before that, the old image is your only seconds-long rollback — a few hundred megabytes of disk are well worth it. I note the image ID and date so I know which image belongs to which working config.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the API doesn't come up at all after the update?
&lt;/h3&gt;

&lt;p&gt;Then look at the container log first (&lt;strong&gt;docker logs homeassistant&lt;/strong&gt;). The most common causes after a major jump are a removed/renamed YAML option or a HACS integration that's incompatible with the new core. If the log shows a concrete error you can fix it surgically; if it's opaque or the downtime drags on, the clean move is the rollback from step 2 rather than minutes of guessing on a live system.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cloudapp.dev/home-assistant-docker-update-rollback" rel="noopener noreferrer"&gt;cloudapp.dev&lt;/a&gt;, where I write about Home Assistant, self-hosting and data infrastructure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>A "Last Motion" Sensor in Home Assistant: Per-Room Timestamps with input_datetime</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Mon, 27 Jul 2026 15:12:22 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/a-last-motion-sensor-in-home-assistant-per-room-timestamps-with-inputdatetime-3pbe</link>
      <guid>https://dev.to/cloudapp_dev/a-last-motion-sensor-in-home-assistant-per-room-timestamps-with-inputdatetime-3pbe</guid>
      <description>&lt;p&gt;A classic motion sensor only knows two things: motion right now, or none right now. What it doesn't answer out of the box is the far more useful question — &lt;strong&gt;when&lt;/strong&gt; was someone last in this room? That single piece of information is the foundation for any presence heuristic: "house empty for two hours", "nobody in the hallway since breakfast", "has anyone even been in the garage today".&lt;/p&gt;

&lt;p&gt;My first instinct was to buy expensive mmWave presence sensors for this. But the plain PIR detectors already on the wall are enough — you just have to store their last on-moment somewhere. In Home Assistant that's an &lt;strong&gt;input_datetime&lt;/strong&gt; helper per room plus a trivial automation. This isn't a light-switching tutorial; it's a pure data pattern that I've since rolled out across the whole house.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why input_datetime instead of last_changed?
&lt;/h2&gt;

&lt;p&gt;Fair objection: every sensor already has a &lt;strong&gt;last_changed&lt;/strong&gt; attribute. The problem is that last_changed flips on every state change — including the switch from on back to off. So you get the time of the last &lt;strong&gt;change&lt;/strong&gt;, not the last &lt;strong&gt;motion&lt;/strong&gt;. And it doesn't survive a Home Assistant restart cleanly: after a reboot the sensor sits freshly at off, last_changed is the boot time, and your history is gone.&lt;/p&gt;

&lt;p&gt;An input_datetime helper fixes both. We write the timestamp only on the transition to on, the value is persistent (it survives restarts), and it's a real, queryable entity you can use in templates, conditions and on the dashboard just like any other sensor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — create one helper per room
&lt;/h2&gt;

&lt;p&gt;Under &lt;strong&gt;Settings → Devices &amp;amp; Services → Helpers&lt;/strong&gt;, create an input_datetime for each room with both &lt;strong&gt;date&lt;/strong&gt; and &lt;strong&gt;time&lt;/strong&gt; enabled. I keep the naming strictly consistent — &lt;strong&gt;letzte_bewegung_&lt;/strong&gt; (last_motion_) — because that makes later scaling trivial: new entity, same schema. If you prefer YAML, the same thing goes into configuration.yaml.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;input_datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;letzte_bewegung_gang&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Letzte Bewegung Gang&lt;/span&gt;
    &lt;span class="na"&gt;has_date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;has_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;letzte_bewegung_garage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Letzte Bewegung Garage&lt;/span&gt;
    &lt;span class="na"&gt;has_date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;has_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing that matters is that both flags are set — without &lt;strong&gt;has_time&lt;/strong&gt; you lose the time of day and the whole pattern goes blunt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — write the timestamp on every motion
&lt;/h2&gt;

&lt;p&gt;Now the actual logic, and it's pleasantly short: an automation that, on the motion sensor's transition to on, writes the current time into the matching helper via &lt;strong&gt;input_datetime.set_datetime&lt;/strong&gt;. I deliberately trigger only on &lt;strong&gt;to: 'on'&lt;/strong&gt; — the off event doesn't interest us, we want to capture the start of the last motion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1711200000001'&lt;/span&gt;
  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Letzte Bewegung Gang&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.bewegungsmelder_gang&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_datetime.set_datetime&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_datetime.letzte_bewegung_gang&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;now().strftime('%Y-%m-%d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;%H:%M:%S')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;now().strftime(...)&lt;/strong&gt; formats the local time into exactly the format set_datetime expects. &lt;strong&gt;mode: single&lt;/strong&gt; is plenty: the action takes milliseconds, parallel runs are irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — the same pattern across rooms
&lt;/h2&gt;

&lt;p&gt;The real charm is repeatability. For every additional room you copy the automation and swap only two things: the &lt;strong&gt;entity_id&lt;/strong&gt; of the motion sensor and the &lt;strong&gt;entity_id&lt;/strong&gt; of the helper. Same shape, different room.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1711200000007'&lt;/span&gt;
  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Letzte Bewegung Garage&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.bewegungsmelder_garage&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_datetime.set_datetime&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_datetime.letzte_bewegung_garage&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;now().strftime('%Y-%m-%d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;%H:%M:%S')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run about a dozen of these automations in parallel — one per PIR. If you prefer something more compact you can build a single automation with multiple triggers and a template over &lt;strong&gt;trigger.to_state&lt;/strong&gt;; I prefer the one-per-room variant because it stays instantly readable in the UI and lets you disable individual rooms on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — relative time on the dashboard
&lt;/h2&gt;

&lt;p&gt;The raw timestamp "2026-07-17 14:32:10" is awkward on a dashboard — what's interesting is "12 minutes ago". For that Home Assistant has the template function &lt;strong&gt;relative_time()&lt;/strong&gt;, which turns a datetime into a human-readable span. In a Markdown card it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;markdown&lt;/span&gt;
&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;## Last motion&lt;/span&gt;

  &lt;span class="s"&gt;- **Hallway:** {{ relative_time(states('input_datetime.letzte_bewegung_gang') | as_datetime) }} ago&lt;/span&gt;

  &lt;span class="s"&gt;- **Garage:** {{ relative_time(states('input_datetime.letzte_bewegung_garage') | as_datetime) }} ago&lt;/span&gt;

  &lt;span class="s"&gt;- **Living room:** {{ relative_time(states('input_datetime.letzte_bewegung_wohnzimmer') | as_datetime) }} ago&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;as_datetime&lt;/strong&gt; converts the string state into a real datetime object that relative_time can process. The result is a compact overview — "Hallway: 3 minutes ago, Garage: 6 hours ago" — that tells you at a glance where there was life most recently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data is good for
&lt;/h2&gt;

&lt;p&gt;Once the timestamps exist, the interesting automations open up. A &lt;strong&gt;"house empty" heuristic&lt;/strong&gt;: if the most recent of all letzte_bewegung_* times is older than, say, 30 minutes, the house counts as unoccupied — cheap presence detection without an mmWave sensor. Or &lt;strong&gt;room-scoped triggers&lt;/strong&gt;: only ramp up the heating in a room if there's been motion there in the last hour. If you like the Modbus/sensor-data way of thinking, you'll find the same "data first, logic second" stance in my &lt;a href="https://www.cloudapp.dev/home-assistant-pv-string-fault-detection" rel="noopener noreferrer"&gt;post on PV string fault detection&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why not just use the sensor's last_changed attribute?
&lt;/h3&gt;

&lt;p&gt;Because last_changed flips on every state change — including the switch from on to off — and doesn't survive a restart: after a reboot the sensor sits freshly at off and last_changed is the boot time. The input_datetime helper, by contrast, stores only the last on-moment, is persistent, and survives restarts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the value survive a Home Assistant restart?
&lt;/h3&gt;

&lt;p&gt;Yes. input_datetime helpers are persistent — their last set value is stored and restored after a reboot. That's one of the main advantages over raw sensor attributes, which start from scratch after a restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a separate automation per room?
&lt;/h3&gt;

&lt;p&gt;No, but it's the most readable option. You can also build a single automation with multiple triggers and pick the matching helper from a mapping via trigger.to_state. I deliberately use one automation per room because it stays instantly understandable in the UI and can be disabled individually.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with any motion sensor?
&lt;/h3&gt;

&lt;p&gt;With any that shows up in Home Assistant as a binary_sensor with on/off states — Zigbee PIR, KNX presence detector or Wi-Fi sensor alike. The pattern doesn't know the hardware, it only reacts to the on transition. On KNX detectors that I also use for light switching, the same timestamp automation runs alongside without issue.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cloudapp.dev/home-assistant-last-motion-sensor-input-datetime" rel="noopener noreferrer"&gt;cloudapp.dev&lt;/a&gt;, where I write about Home Assistant, self-hosting and data infrastructure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>automation</category>
      <category>smarthome</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Motion Light With Time-of-Day Brightness in Home Assistant: the choose Pattern (30% Night, 70% Morning, 100% Day)</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:02:07 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/motion-light-with-time-of-day-brightness-in-home-assistant-the-choose-pattern-30-night-70-2a0o</link>
      <guid>https://dev.to/cloudapp_dev/motion-light-with-time-of-day-brightness-in-home-assistant-the-choose-pattern-30-night-70-2a0o</guid>
      <description>&lt;p&gt;A simple motion light in the stairwell is quick to build: motion detected, light on, off again after a few minutes. You only notice the problem at night. In my own stairwell, the sudden 100 % light at three in the morning blinded me so badly that I was wide awake afterwards. During the day, on the other hand, dimmed light is too weak to see the steps safely.&lt;/p&gt;

&lt;p&gt;The fix isn't a second setup or an expensive adaptive-lighting layer — it's a single &lt;strong&gt;choose:&lt;/strong&gt; block in the motion automation that grades brightness by time of day: 30 % at night, 70 % in the early morning, full 100 % otherwise. Plus the one real-world lesson that cost me two evenings of debugging — against flicker, a longer delay helps, not a shorter one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on/off alone isn't enough
&lt;/h2&gt;

&lt;p&gt;A fixed brightness level is always set for the wrong time of day. Set it to full and it blinds you at night. Set it dimmed and it's too dark during the day. That's exactly where the pattern comes in: motion stays the trigger, but &lt;strong&gt;how bright&lt;/strong&gt; the light comes on is decided by a short time branch inside the actions. No second sensor, no second automation — just one choose block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — the choose block for time-of-day brightness
&lt;/h2&gt;

&lt;p&gt;The core is the &lt;strong&gt;choose:&lt;/strong&gt; block in the actions. It checks conditions top to bottom and takes the first branch that matches; if none does, the default runs. I use three tiers: 22:00–06:00 dimmed to 30 %, 06:00–08:00 medium at 70 %, and the rest of the day at full 100 %.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;choose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Spaete Nacht (22:00-06:00): gedimmt, blendet nicht&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
            &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;22:00:00"&lt;/span&gt;
            &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06:00:00"&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
            &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stiegenhaus_eg&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;# Frueher Morgen (06:00-08:00): mittlere Helligkeit&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
            &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06:00:00"&lt;/span&gt;
            &lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;08:00:00"&lt;/span&gt;
        &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
            &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stiegenhaus_eg&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;70&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# Default: volle Helligkeit&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_on&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stiegenhaus_eg&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;brightness_pct&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter. First, &lt;strong&gt;order&lt;/strong&gt; is decisive: the night branch comes first because its window doesn't overlap the morning branch — with overlapping windows the first match wins. Second, the &lt;strong&gt;time&lt;/strong&gt; condition is correct across midnight: after "22:00:00" / before "06:00:00" covers the night properly, because Home Assistant treats that as one continuous window spanning the day boundary. Replace &lt;strong&gt;light.stiegenhaus_eg&lt;/strong&gt; with your own light entity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — turning it back off cleanly
&lt;/h2&gt;

&lt;p&gt;The second part is switching off. Most motion sensors report "no more motion" fairly quickly once you stand still. If you turn off directly on that, the light keeps flicking on and off whenever someone pauses on the stairs or stands in the hall on a call. The &lt;strong&gt;for:&lt;/strong&gt; filter on the off trigger waits until the sensor has seen nothing for a continuous stretch of time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.bewegungsmelder_stiegenhaus_eg&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off"&lt;/span&gt;
    &lt;span class="c1"&gt;# Flacker-Fix: lieber 5 Min als 1 Min - kurze Delays lassen das Licht&lt;/span&gt;
    &lt;span class="c1"&gt;# bei stillem Sitzen an/aus springen&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light.turn_off&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;light.stiegenhaus_eg&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This block is the separate off automation (or the off trigger of the same automation). The trigger only fires when the sensor has stayed "off" for 5 uninterrupted minutes — if someone moves again in that window the timer resets and the light stays on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flicker fix: raise the delay, don't lower it
&lt;/h2&gt;

&lt;p&gt;This is the lesson that feels counter-intuitive. When the light flickers — rhythmically on and off while someone sits still or walks the stairs slowly — the first instinct is to &lt;strong&gt;shorten&lt;/strong&gt; the off delay so it reacts faster. That makes it worse. PIR motion sensors detect heat movement; sit still and the sensor drops to "off", you breathe or reach for a glass and it goes "on" again. A short delay lets the light chase that jitter.&lt;/p&gt;

&lt;p&gt;The solution is a &lt;strong&gt;longer for: delay&lt;/strong&gt;. At 5 minutes the timer bridges the dead phases where the PIR sees no motion even though someone is still there. Better the light stays on a few minutes too long than a blinking stairwell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sensible delays per room type
&lt;/h2&gt;

&lt;p&gt;The right for: duration depends on how long you typically stay in a room with little movement. In a stairwell or pass-through hall, 2–3 minutes is enough, because you rarely stand still long. In a room where you sit — a study, a reading chair — go higher, to 8–10 minutes, or the light cuts out during focused work. My stairwell runs on 5 minutes as a good middle ground: generous enough against flicker, short enough that no light burns for hours in an empty stairwell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending: only after dark, or with more tiers
&lt;/h2&gt;

&lt;p&gt;If you don't want the light to come on at all in bright daylight, add a &lt;strong&gt;sun&lt;/strong&gt; condition (sun.sun = below_horizon) or an illuminance sensor as an extra condition on the on trigger. The choose pattern itself scales freely: add more time windows (say a midday tier) or run different brightnesses per weekday. If you're curious how the same motion sensors feed presence heuristics, see the &lt;a href="https://www.cloudapp.dev/home-assistant-last-motion-sensor-input-datetime" rel="noopener noreferrer"&gt;"last motion" sensor&lt;/a&gt; — it builds on the same triggers.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why a choose block instead of several automations?
&lt;/h3&gt;

&lt;p&gt;You could build a separate automation per time of day with its own time condition, but that scatters the logic across three places and makes changes error-prone. The choose block keeps the whole time-of-day branch in one automation — one glance, one place to tune. That's exactly what choose: is for: several mutually exclusive branches under one trigger.&lt;/p&gt;

&lt;h3&gt;
  
  
  My light still flickers — what now?
&lt;/h3&gt;

&lt;p&gt;First raise the for: delay further (say to 8–10 minutes) and see if it disappears — usually it does. If it persists, it's often the sensor hardware: some PIR units have their own cool-down/blocking time set too short, or the detection field is so narrow that a small turn drops you out of it. Then it comes down to sensor placement, or an mmWave presence sensor that also detects still occupancy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I leave it fully off at night instead of 30 %?
&lt;/h3&gt;

&lt;p&gt;Yes — just replace the light.turn_on in the night branch with no action, or drop the branch entirely (the default won't take over since the trigger still fires). Cleaner is to keep the night branch and dim very low there (say 5–10 %): enough to see the steps without waking up. Going fully dark on a staircase at night is a safety risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with any light or only dimmable ones?
&lt;/h3&gt;

&lt;p&gt;brightness_pct assumes the light entity supports dimming. With a plain on/off switch, Home Assistant ignores the brightness value and just turns it on. Then the time-of-day grading does little — in that case a dimmable bulb or a dimming actuator is worth it, especially in the stairwell where the night-time glare is the actual problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I track when a room was last occupied, not just current motion?
&lt;/h3&gt;

&lt;p&gt;That's a separate pattern — an &lt;a href="https://www.cloudapp.dev/home-assistant-last-motion-sensor-input-datetime" rel="noopener noreferrer"&gt;input_datetime updated on last motion&lt;/a&gt; — I use it alongside this brightness automation to answer “how long has this room been empty”, not just “is it occupied right now”.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cloudapp.dev/home-assistant-motion-light-time-of-day-brightness" rel="noopener noreferrer"&gt;cloudapp.dev&lt;/a&gt;, where I write about Home Assistant, self-hosting and data infrastructure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>automation</category>
      <category>smarthome</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Self-Hosted Modern Data Stack Under €200/Month</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Thu, 23 Jul 2026 07:41:43 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/a-self-hosted-modern-data-stack-under-eu200month-472d</link>
      <guid>https://dev.to/cloudapp_dev/a-self-hosted-modern-data-stack-under-eu200month-472d</guid>
      <description>&lt;p&gt;Every managed data platform quote I got landed in the same place: over a thousand euros a month before a single dashboard existed, on US-owned infrastructure, with my data living somewhere I didn't control. I needed an analytics platform — ingest a handful of sources, model them, put numbers in front of non-technical people — and the &lt;strong&gt;modern data stack&lt;/strong&gt; has perfectly good open-source answers for every layer of that. So I self-hosted the whole thing on Hetzner. It runs for under €200 a month. Here's the stack, the real trade-offs, and the two decisions I got wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "managed" actually costs
&lt;/h2&gt;

&lt;p&gt;The pitch for managed platforms is that they save you operational work, and they do. What the pitch skips is the shape of the bill. Microsoft Fabric quoted over €1,000/month for my workload. Snowflake and Databricks are usage-priced, which sounds cheaper until a backfill or a careless dashboard scans more than you budgeted for — the number is unpredictable by design. And all three put my data on infrastructure I don't own, under a jurisdiction I'd have to explain to a data protection officer.&lt;/p&gt;

&lt;p&gt;None of that is a dealbreaker for everyone. If you have no ops capacity and a generous budget, managed is the right answer. But I had the opposite: a tight budget, a hard requirement for EU data sovereignty, and enough Kubernetes and SQL to run infrastructure myself. That combination points straight at self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, layer by layer
&lt;/h2&gt;

&lt;p&gt;The "modern data stack" is really just a set of roles — storage, ingestion, a query engine, transformation, orchestration, and a BI layer — each of which now has a solid open-source option. Here's what I picked and, more usefully, why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: object storage + Apache Iceberg
&lt;/h3&gt;

&lt;p&gt;The lake sits in Hetzner Object Storage (S3-compatible) as &lt;strong&gt;Apache Iceberg&lt;/strong&gt; tables. Iceberg gives you table semantics — schema evolution, time travel, atomic writes — on top of plain Parquet files you fully own. The format is open, so nothing about this layer locks me to a vendor or even to the query engine. That single choice is what makes the rest of the stack swappable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query engine: ClickHouse on dedicated hardware
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;ClickHouse&lt;/strong&gt; is the core, and it runs on a dedicated Hetzner box rather than inside my Kubernetes cluster. That was deliberate: an analytics column store wants direct access to fast local NVMe and predictable memory, and putting it in K8s next to noisy neighbours buys you nothing but scheduling headaches. A dedicated machine with a lot of RAM and mirrored NVMe costs a fraction of a managed warehouse and outruns it on the queries I actually run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transformation: dbt
&lt;/h3&gt;

&lt;p&gt;Transformation is &lt;strong&gt;dbt&lt;/strong&gt; with the ClickHouse adapter. dbt is the one layer where I'd have made the same choice managed or not — it's become the default for turning raw tables into modelled ones, and the &lt;a href="https://github.com/ClickHouse/dbt-clickhouse" rel="noopener noreferrer"&gt;dbt-clickhouse adapter&lt;/a&gt; is mature enough for real work. Models are version-controlled SQL, tested in CI, and materialised straight into ClickHouse. It's niche enough that you'll occasionally hit an adapter edge the Postgres or Snowflake worlds never see, but for standard modelling it simply works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orchestration: Dagster
&lt;/h3&gt;

&lt;p&gt;Everything is stitched together by &lt;strong&gt;Dagster&lt;/strong&gt;, running as open-source in Kubernetes. I went with Dagster over Airflow because it models the pipeline as assets — "this table exists and depends on that source" — rather than as a bag of tasks. When the question is "is this dashboard's data fresh and where did it come from", an asset graph answers it directly. The whole ingest → dbt → BI chain is one lineage graph I can look at.&lt;/p&gt;

&lt;h3&gt;
  
  
  BI: a self-hosted dashboard layer
&lt;/h3&gt;

&lt;p&gt;For the last mile — numbers in front of people who will never write SQL — I use a self-hosted BI tool backed by its own Postgres. It reads straight from ClickHouse, so dashboards are fast, and because it's self-hosted the data never leaves the platform to be rendered. This is the layer that justifies the whole thing to everyone who isn't an engineer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingestion: the part I got wrong first
&lt;/h2&gt;

&lt;p&gt;My original design pulled changes out of the source databases with Debezium, the standard change-data-capture route. On paper it's the right answer: near-real-time, battle-tested, exactly what CDC is for. In practice it was too much moving machinery for what I actually needed. Debezium wants a Kafka-shaped world around it, and my sources didn't change fast enough to justify that weight.&lt;/p&gt;

&lt;p&gt;So I reversed it. The batch sync now runs on &lt;strong&gt;dlt&lt;/strong&gt; — a small Python library that reads a source and lands it in the lake on a schedule — with Dagster triggering it. It's less impressive on a diagram and far less to operate, and for data that's fresh enough at hourly or daily granularity it's simply the correct trade. The lesson I keep relearning: pick the ingestion cadence your data actually has, not the fastest one available.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs — money and otherwise
&lt;/h2&gt;

&lt;p&gt;The infrastructure bill is under €200/month: the dedicated ClickHouse machine, the object storage, and a small Kubernetes cluster carrying the orchestration and BI pods. That's a fraction of the managed quote, on hardware in an EU data centre, with my data in open formats I could move anywhere tomorrow.&lt;/p&gt;

&lt;p&gt;The honest cost is on the other side of the ledger. Self-hosting means you own the pager. A managed warehouse that falls over is someone else's 3 a.m.; mine is mine. Until I add a second node the query engine is a single point of failure, and every component is one more thing to patch, back up and understand. If you can't comfortably operate Kubernetes and a database, that operational load will eat any money you saved. That number is real, but it isn't free — it's a trade of cash for control and competence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;For this situation, without hesitation. The combination that made it right — a real budget ceiling, a hard EU-sovereignty requirement, and the in-house ability to run it — is common enough that a lot of teams are quietly in the same position and defaulting to managed anyway. If that's you, the modern data stack self-hosts cleanly, and the pieces are all open source and genuinely good now in a way they weren't a few years ago.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is a self-hosted data stack really cheaper than Snowflake or Fabric?
&lt;/h3&gt;

&lt;p&gt;On raw infrastructure, dramatically — under €200/month here versus €1,000+ quoted. But that number ignores your own time. If you have to hire or divert an engineer to run it, the managed premium can be the cheaper option. It's cheaper in money, not necessarily in total cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why ClickHouse instead of just Postgres?
&lt;/h3&gt;

&lt;p&gt;Postgres is a row store; analytics queries scan columns across millions of rows, which is exactly what a column store like ClickHouse is built for. For transactional workloads Postgres wins; for aggregations over large tables ClickHouse is often orders of magnitude faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Kubernetes for this?
&lt;/h3&gt;

&lt;p&gt;No. I use it because I already ran a cluster and it makes the orchestration and BI pods easy to manage, but you can run the same stack with Docker Compose on a couple of machines. The query engine deliberately sits on dedicated hardware either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Dagster over Airflow?
&lt;/h3&gt;

&lt;p&gt;Airflow models tasks; Dagster models data assets and their lineage. For an analytics pipeline where the real question is "is this table fresh and where did it come from", the asset model answers it natively. Airflow is the safer choice if your team already knows it well.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cloudapp.dev/self-hosted-modern-data-stack-hetzner" rel="noopener noreferrer"&gt;cloudapp.dev&lt;/a&gt;, where I write about self-hosting, Home Assistant and data infrastructure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>clickhouse</category>
      <category>dbt</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Decoupling a KNX Alarm from the JUNG Panel in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:38:33 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/decoupling-a-knx-alarm-from-the-jung-panel-in-home-assistant-4o7b</link>
      <guid>https://dev.to/cloudapp_dev/decoupling-a-knx-alarm-from-the-jung-panel-in-home-assistant-4o7b</guid>
      <description>&lt;p&gt;The house came with a &lt;strong&gt;JUNG EAM4000&lt;/strong&gt; KNX alarm panel, and for years it owned the entire security story. It gated the motion detectors, it decided whether the place was armed, and it drove the sirens. When I started pulling the house into &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;Home Assistant&lt;/a&gt;, the obvious move was to read the alarm out of that panel and call it done. I didn't do that, and this post is about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive way couples you to the panel
&lt;/h2&gt;

&lt;p&gt;The tempting approach is to tap the panel's own outputs: wire its zone and sabotage objects into HA and read them as sensors. It works on day one. But then HA only ever knows what the panel decides to tell it. When the panel is disarmed, those zone outputs go quiet, so HA sees nothing either. Your automation logic now lives downstream of someone else's arming logic, and you've inherited a dependency you can't easily reason about or change.&lt;/p&gt;

&lt;p&gt;I didn't want Home Assistant to be a slave to the JUNG panel. I wanted HA to be the thing that decides, and the panel to become, at most, a peer on the bus. So I inverted who owns the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  HA owns the state and exposes it back onto the bus
&lt;/h2&gt;

&lt;p&gt;The core trick is almost embarrassingly simple once you see it. Instead of reading the alarm's status &lt;strong&gt;out&lt;/strong&gt; of the panel, I made Home Assistant the owner of the alarm state and exposed it &lt;strong&gt;back&lt;/strong&gt; onto the KNX bus. Two HA &lt;strong&gt;input_booleans&lt;/strong&gt; — an 'armed/active' object and a 'ready to arm' object — get published to KNX group addresses as type &lt;strong&gt;binary&lt;/strong&gt; via an &lt;strong&gt;expose:&lt;/strong&gt; block in &lt;strong&gt;knx.yaml&lt;/strong&gt;. The bus now reflects HA's truth, not the panel's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# knx.yaml — HA OWNS the alarm state and pushes it back onto the bus&lt;/span&gt;
&lt;span class="c1"&gt;# (status + 'ready' are HA input_booleans exposed to KNX, not read from the panel)&lt;/span&gt;
&lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binary"&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_boolean.knx_ha_alarm_status"&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x/1/10"&lt;/span&gt;   &lt;span class="c1"&gt;# HA-owned 'armed' object on the bus (real GA masked)&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binary"&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_boolean.knx_ha_alarm_bereit"&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x/1/11"&lt;/span&gt;   &lt;span class="c1"&gt;# HA-computed 'ready to arm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note these exposed objects deliberately omit &lt;strong&gt;sync_state&lt;/strong&gt; — HA is the &lt;strong&gt;writer&lt;/strong&gt; here, it's allowed to push. The addresses use the normal 3-level KNX form; I've masked the leading group because this is a security topic and the real map stays private.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read presence off the light address, not the alarm zone
&lt;/h2&gt;

&lt;p&gt;Here's the detail I'm genuinely happy about. The motion detectors already fire a &lt;strong&gt;light&lt;/strong&gt; telegram to switch corridor and stair lighting, and they send it regardless of whether the alarm is armed. So I read each detector into HA through its &lt;strong&gt;light&lt;/strong&gt; group address, not through the panel's alarm zone — a plain &lt;strong&gt;binary_sensor&lt;/strong&gt; with &lt;strong&gt;device_class: motion&lt;/strong&gt; pointed at the light status object. The payoff: HA sees presence even when the alarm is fully disarmed — exactly the information the panel would have hidden from me. There are seven &lt;strong&gt;JUNG 3361-1&lt;/strong&gt; detectors across the property, and I split them into a five-detector cellar set for a 'night' mode and the full seven-detector set for 'away'.&lt;/p&gt;

&lt;p&gt;Every one of those read-only sensors sets &lt;strong&gt;sync_state: false&lt;/strong&gt; — window contacts, motion detectors, tamper. That stops HA from issuing GroupValueRead requests or writing back, so on those objects HA is a passive listener and can't disturb the alarm's own bus traffic. The exposed alarm objects above deliberately omit it, because there HA is the writer.&lt;/p&gt;

&lt;h2&gt;
  
  
  'Ready to arm' is computed in HA, and arming is gated on HA's flag
&lt;/h2&gt;

&lt;p&gt;The readiness lamp ('Bereit') isn't the panel's calculation anymore — it's mine. Window and opening contacts are modelled as &lt;strong&gt;binary_sensor&lt;/strong&gt; with &lt;strong&gt;device_class: window&lt;/strong&gt; straight off their KNX status objects, and the garage gets its own &lt;strong&gt;device_class: garage_door&lt;/strong&gt; sensor. Two automations watch floor-level window groups for the ground and upper floors, and only when every window on both floors reports closed do they flip the exposed 'ready' boolean on. That computed readiness is then pushed back to KNX so the lamp on the bus shows HA's answer.&lt;/p&gt;

&lt;p&gt;Arming converges on one source of truth. Two 'sync' automations keep a physical KNX wall switch and &lt;strong&gt;input_boolean.knx_ha_alarm_status&lt;/strong&gt; in lock-step in both directions, so arming from the wall and arming from the app land on the same flag. And every escalation is gated on that flag: a window or door open &lt;strong&gt;triggers&lt;/strong&gt; the automation, but nothing escalates unless HA's own armed flag is set. The JUNG panel is no longer in that decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Every escalation is gated on HA's own armed flag, not the JUNG panel:&lt;/span&gt;
&lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_boolean.knx_ha_alarm_status&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;off'&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;   &lt;span class="c1"&gt;# debounce a bouncing reed contact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;strong&gt;for: seconds: 5&lt;/strong&gt; is intentional anti-bounce — a flickering reed contact has to hold open for five seconds before it counts. On a real trigger HA writes the offending opening plus a timestamp into an &lt;strong&gt;input_text&lt;/strong&gt; helper, logs it to the logbook, raises a persistent notification, sends a critical-priority push to the household phones (sound critical, volume 1.0, so it punches through iOS focus mode), and turns on a local buzzer plus lighting in a set of areas. The buzzer auto-deactivates five seconds after the armed boolean goes off again. All of it is orchestrated in HA, independent of the panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety net, and the honest trade-off
&lt;/h2&gt;

&lt;p&gt;Before any of the HA work, I did one thing in ETS: I stripped the siren, flash and transmission group addresses out of the JUNG central unit. The point was that even if the panel mis-fired during the rewire, nothing would physically sound. HA becomes the only thing that can raise an alarm. (Editing the central unit needs JUNG's vendor ETS plug-in on Windows with a KNX/IP gateway — you can't reconfigure it from generic ETS.)&lt;/p&gt;

&lt;p&gt;One real gotcha along the way: some detectors were still brightness-gated in ETS ('helligkeitsabhängig'), so they only sent their movement telegram in the dark. Reading presence off the light address means you have to reprogram those to send brightness-independently, or they show up dead in HA. A few of mine did exactly that until I caught it.&lt;/p&gt;

&lt;p&gt;The honest trade-off: I gave up the panel's certified, tamper-monitored signal path for flexibility. The tamper contacts still exist — they're mapped as &lt;strong&gt;device_class: tamper&lt;/strong&gt; sensors — but I'm no longer routing security through the panel's coupling. If you need a monitored, insurance-grade alarm path, that certified panel still has a real job. For a house where I want HA to actually own the logic, inverting ownership and listening passively on the bus was the cleaner build. If you're following the series, this leans on the same KNX foundation as the earlier &lt;a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant" rel="noopener noreferrer"&gt;HACS&lt;/a&gt; and Docker posts.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
    <item>
      <title>KNX Thermostats Drop to 'Standby' After Every Home Assistant Restart - A Startup Automation Fix</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:19:28 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/knx-thermostats-drop-to-standby-after-every-home-assistant-restart-a-startup-automation-fix-4lh1</link>
      <guid>https://dev.to/cloudapp_dev/knx-thermostats-drop-to-standby-after-every-home-assistant-restart-a-startup-automation-fix-4lh1</guid>
      <description>&lt;p&gt;It's a quiet bug that only bites you when the flat is cold in the morning: after every Home Assistant restart ‚Äî an update, an add-on install, a container restart, a power cut ‚Äî all of my KNX thermostats sat on &lt;strong&gt;'standby'&lt;/strong&gt;. No heating, no error, the mode simply gone. By the time I noticed it was usually hours too late and the radiators stone cold.&lt;/p&gt;

&lt;p&gt;The cause is both harmless and infuriating: on restart the KNX integration briefly loses its connection to the bus, and many KNX climate actuators fall back to a safe default state ‚Äî namely 'standby'. Home Assistant no longer knows the previous mode after the reboot and doesn't restore it on its own. The fix is a small but precisely timed startup automation. It cost me about 30 minutes ‚Äî and the waking-up-cold problem has been gone ever since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: 'standby' instead of 'heat'
&lt;/h2&gt;

&lt;p&gt;Concretely: before the restart a thermostat is on &lt;strong&gt;hvac_mode: heat&lt;/strong&gt; with preset &lt;strong&gt;comfort&lt;/strong&gt;. After the restart the same climate entity shows &lt;strong&gt;standby&lt;/strong&gt; (or 'off' on some actuators). The setpoint drops out of the control loop and the radiator stays cold. In the history graph you see the kink exactly at the restart moment. Important to understand: this is not a fault in your automations ‚Äî it's an initialisation gap right after boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a simple 'set mode' call isn't enough
&lt;/h2&gt;

&lt;p&gt;The obvious reflex ‚Äî just fire &lt;strong&gt;climate.set_hvac_mode&lt;/strong&gt; at startup ‚Äî fails, because in the first seconds after boot the climate entities are often still &lt;strong&gt;unavailable&lt;/strong&gt;. The KNX bus has to connect first, the actuators have to report their state. Send the command too early and it goes nowhere, leaving the thermostat on standby. That's exactly why the automation needs two ingredients: a trigger on HA start &lt;strong&gt;and&lt;/strong&gt; a short wait until the bus is back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a timed startup automation
&lt;/h2&gt;

&lt;p&gt;The automation below triggers on the &lt;strong&gt;homeassistant start&lt;/strong&gt; event, waits &lt;strong&gt;30 seconds&lt;/strong&gt; for the KNX bus to connect and all climate entities to become available, and then sets the right state depending on night mode: if night mode is on, every thermostat goes to preset &lt;strong&gt;economy&lt;/strong&gt;; otherwise to &lt;strong&gt;heat&lt;/strong&gt; plus preset &lt;strong&gt;comfort&lt;/strong&gt;. A simple input_boolean helper ‚Äî which I share with the master heating switch ‚Äî drives the day/night branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;heating_startup_initialize&lt;/span&gt;
  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Heizung:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Modus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;beim&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Start&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;initialisieren'&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
    &lt;span class="s"&gt;Setzt alle Thermostate beim HA-Start auf den korrekten Modus,&lt;/span&gt;
    &lt;span class="s"&gt;basierend auf dem Nachtmodus-Status&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homeassistant&lt;/span&gt;
    &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;start&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# the KNX bus needs a few seconds after the restart until all&lt;/span&gt;
  &lt;span class="c1"&gt;# climate entities are connected/available again&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;choose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# night mode active -&amp;gt; economy&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
        &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input_boolean.heizung_nachtmodus&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
      &lt;span class="na"&gt;sequence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;climate.set_preset_mode&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;preset_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;economy&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_wohnzimmer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_buro&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kuche&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_schlafzimmer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_1&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_2&lt;/span&gt;
    &lt;span class="c1"&gt;# otherwise day mode -&amp;gt; heat + comfort&lt;/span&gt;
    &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;climate.set_hvac_mode&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;hvac_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;heat&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_wohnzimmer&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_buro&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kuche&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_schlafzimmer&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_1&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_2&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;climate.set_preset_mode&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;preset_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;comfort&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_wohnzimmer&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_buro&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kuche&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_schlafzimmer&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_1&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;climate.thermostat_kinderzimmer_2&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entity list here targets my six rooms ‚Äî replace it with your own &lt;strong&gt;climate.&lt;/strong&gt; entities. The room names are generic placeholders; fill in office, living room, kitchen and so on exactly as they're named in your setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why exactly 30 seconds of delay
&lt;/h2&gt;

&lt;p&gt;The 30 seconds aren't arbitrary. On my system the KNX integration reproducibly took about 10 to 20 seconds after boot before all climate entities flipped from 'unavailable' to a real state. 30 seconds gives a safe buffer without noticeably delaying when the flat warms up. On slower hardware (a Raspberry Pi, lots of integrations) it can make sense to go to 45 or 60 seconds. Rule of thumb: better to wait a little too long than to send the command into the void.&lt;/p&gt;

&lt;p&gt;If you want it more robust, you can replace the fixed &lt;strong&gt;delay&lt;/strong&gt; with a &lt;strong&gt;wait_template&lt;/strong&gt; that waits until a representative climate entity is no longer 'unavailable' ‚Äî but for a home setup the plain 30-second delay is proven and entirely sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day/night branch via choose
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;choose&lt;/strong&gt; block makes sure that a restart at three in the morning doesn't ramp everything up to full comfort temperature. If &lt;strong&gt;input_boolean.heizung_nachtmodus&lt;/strong&gt; is 'on', all thermostats get the economy preset (set back); in the default branch ‚Äî i.e. during the day ‚Äî &lt;strong&gt;heat&lt;/strong&gt; is set as the HVAC mode first, then &lt;strong&gt;comfort&lt;/strong&gt; as the preset. I share this night-mode helper with the central heating switch, so startup and manual control use the same source of truth.&lt;/p&gt;

&lt;p&gt;I use the same building-protection/preset logic in my &lt;a href="https://www.cloudapp.dev/home-assistant-knx-window-open-heating-off" rel="noopener noreferrer"&gt;window-contact-triggered frost-protection automation&lt;/a&gt; too ‚Äî both act on the same thermostat pool, just on different occasions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without restarting Home Assistant
&lt;/h2&gt;

&lt;p&gt;You don't have to fully reboot every time to check the automation. In Developer Tools you can trigger the automation manually via 'Run' ‚Äî the homeassistant-start trigger is skipped, but the actions (delay + choose) run exactly as they would. First set a thermostat to 'standby' as a test and watch in the more-info dialog how it snaps back to 'comfort'/heat after a good 30 seconds. For the real test, do a deliberate restart and check the last-changed timestamps of the climate entities.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why do the KNX thermostats drop to standby on restart at all?
&lt;/h3&gt;

&lt;p&gt;On restart the KNX integration briefly loses the bus connection. Many KNX climate actuators fall into a safe default on connection loss ‚Äî often 'standby' or 'off'. Home Assistant does not reconstruct the previous mode after boot automatically, so the actuator stays in the default until something actively resets it. That's precisely the gap the startup automation fills.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isn't a shorter delay than 30 seconds enough?
&lt;/h3&gt;

&lt;p&gt;Possibly, but it's risky. If you send the command before the climate entities are available, it goes nowhere and the thermostat stays on standby ‚Äî the bug is back. 30 seconds is a conservative, proven value. On fast hardware you can go down to 15 seconds, but then verify across several restarts that it reliably takes effect.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I don't have a night-mode helper at all?
&lt;/h3&gt;

&lt;p&gt;Then you don't need the choose block. Keep just the default branch: 30 seconds delay, then climate.set_hvac_mode heat and climate.set_preset_mode comfort for all thermostats. You only create input_boolean.heizung_nachtmodus if you actually want a nightly set-back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work for other climate integrations, not just KNX?
&lt;/h3&gt;

&lt;p&gt;Yes. The pattern ‚Äî trigger on homeassistant start, wait briefly for the entities to become available, then force the desired mode ‚Äî is generic. Any climate integration that falls into an unwanted default after a restart can be initialised this way. You only adjust the entity IDs and, if needed, the delay duration.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>automation</category>
      <category>smarthome</category>
    </item>
    <item>
      <title>Window Open, Heating Off: Auto-Switch Each KNX Thermostat to Frost Protection in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Sat, 11 Jul 2026 19:12:46 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/window-open-heating-off-auto-switch-each-knx-thermostat-to-frost-protection-in-home-assistant-gm</link>
      <guid>https://dev.to/cloudapp_dev/window-open-heating-off-auto-switch-each-knx-thermostat-to-frost-protection-in-home-assistant-gm</guid>
      <description>&lt;p&gt;In winter I heat against the open window. Every time I tilt a window for a quick airing, the KNX thermostat below dutifully ramps up and blasts heating energy straight outside ‚Äî for minutes, until someone notices the room going cold and closes it again. Across a heating season that adds up to real money going out the window, literally.&lt;/p&gt;

&lt;p&gt;The fix is a simple, per-room duplicable automation: window contact opens ‚Üí that room's thermostat goes to frost protection; window closes ‚Üí back to comfort. The one trick almost every tutorial leaves out is a 5-second debounce against false triggers. Here's my setup, step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why building_protection and not just "off"
&lt;/h2&gt;

&lt;p&gt;The obvious reflex is to switch the thermostat fully off while the window is open (hvac_mode off or preset standby). That's dangerous: with a thermostat you forget or a window contact that gets stuck, the room can then cool down indefinitely ‚Äî in the worst case below freezing on an exterior wall. So I switch to the &lt;strong&gt;building_protection&lt;/strong&gt; preset (frost guard, typically ~7 ¬∞C). The radiator stays supervised and kicks in if it genuinely gets cold, but it never heats the open window. That's the whole difference between "saving energy" and "a burst pipe in January".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 ‚Äî the window contact as a KNX binary_sensor
&lt;/h2&gt;

&lt;p&gt;Every window I want to watch gets a &lt;strong&gt;binary_sensor&lt;/strong&gt; with device_class window. That matters because Home Assistant derives the correct "open/closed" state and the right icons from it. The state address is the KNX group address your window contact reports on ‚Äî shown here as X/X/X placeholders; fill in your own. &lt;strong&gt;sync_state: false&lt;/strong&gt; avoids needless polling of the bus, since the contact sends its state on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;binary_sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fenster&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Buero&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sensor"&lt;/span&gt;
    &lt;span class="na"&gt;state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X/X/X"&lt;/span&gt;
    &lt;span class="na"&gt;sync_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;window&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fenster&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Wohnzimmer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sensor"&lt;/span&gt;
    &lt;span class="na"&gt;state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X/X/X"&lt;/span&gt;
    &lt;span class="na"&gt;sync_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;window&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fenster&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Kinderzimmer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sensor"&lt;/span&gt;
    &lt;span class="na"&gt;state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X/X/X"&lt;/span&gt;
    &lt;span class="na"&gt;sync_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;window&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fenster&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Kueche&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sensor"&lt;/span&gt;
    &lt;span class="na"&gt;state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X/X/X"&lt;/span&gt;
    &lt;span class="na"&gt;sync_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;window&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fenster&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Kinderzimmer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sensor"&lt;/span&gt;
    &lt;span class="na"&gt;state_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X/X/X"&lt;/span&gt;
    &lt;span class="na"&gt;sync_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;window&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One entry per window. The names are just examples ‚Äî generic room names like "Buero" (office) or "Wohnzimmer" (living room) keep the automations readable later on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 ‚Äî the per-room automation pair
&lt;/h2&gt;

&lt;p&gt;For each room I need two automations: one disables the heating on open, one re-enables it on close. Both hang off the same window contact and control the same thermostat. Here's the pair for the office:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Heizung Buero Deaktivieren&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;binary_sensor.fenster_buro_status_sensor&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;off'&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;climate.set_preset_mode&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;preset_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;building_protection&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;climate.thermostat_buro&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Heizung Buero Aktivieren&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;binary_sensor.fenster_buro_status_sensor&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;off'&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;climate.set_preset_mode&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;preset_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;comfort&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;climate.thermostat_buro&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;single&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On open it sets &lt;strong&gt;building_protection&lt;/strong&gt;; on close, back to &lt;strong&gt;comfort&lt;/strong&gt;. Which preset modes your KNX thermostat actually supports, and how they map onto the bus, is covered in detail in my &lt;a href="https://www.cloudapp.dev/en-US/home-assistant-knx-thermostat-preset-modes" rel="noopener noreferrer"&gt;post on KNX thermostat preset modes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 ‚Äî understanding the 5-second debounce
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;for: {seconds: 5}&lt;/strong&gt; is the real trick and the reason this automation doesn't drive you mad in daily use. Without it, every micro-movement of the contact fires immediately: a reed contact sometimes bounces on closing, the window rattles in its frame during airing, and the KNX bus occasionally delivers brief flutter telegrams. Each of those glitches would otherwise trigger a pointless mode switch.&lt;/p&gt;

&lt;p&gt;With the 5-second filter, the automation only fires once the window has &lt;strong&gt;actually&lt;/strong&gt; been open (or closed) for five seconds. Short rattling is ignored, real airing is detected. Five seconds is short enough that no noticeable heating energy is lost, and long enough to mask all the flutter junk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mapping scales per room
&lt;/h2&gt;

&lt;p&gt;The nice thing about the pattern: it's bluntly duplicable. One window-contact binary_sensor plus one climate thermostat per room, and the automation pair gets copied once with its two entity_ids swapped out. Here's my mapping table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Raum            Fensterkontakt                              Thermostat&lt;/span&gt;
&lt;span class="c1"&gt;# Buero           binary_sensor.fenster_buro_status_sensor    climate.thermostat_buro&lt;/span&gt;
&lt;span class="c1"&gt;# Wohnzimmer      binary_sensor.fenster_wohnzimmer_...        climate.thermostat_wohnzimmer&lt;/span&gt;
&lt;span class="c1"&gt;# Kueche          binary_sensor.fenster_kueche_...            climate.thermostat_kueche&lt;/span&gt;
&lt;span class="c1"&gt;# Kinderzimmer 1  binary_sensor.fenster_kinderzimmer_1_...    climate.thermostat_kinderzimmer_1&lt;/span&gt;
&lt;span class="c1"&gt;# Kinderzimmer 2  binary_sensor.fenster_kinderzimmer_2_...    climate.thermostat_kinderzimmer_2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have several windows per room, you can list them all in the trigger's entity_id and express "at least one window open" via a group or a template condition ‚Äî the logic stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it works
&lt;/h2&gt;

&lt;p&gt;After reloading I test each room once by hand: open the window, wait a moment, then check in the thermostat overview that the preset jumped to building_protection (the list with secondary_info "last-changed" shows the timestamp). Close the window, wait five seconds, back to comfort. If the switch happens but too early or too late, the for duration is almost always the knob to turn.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why not hvac_mode off instead of building_protection?
&lt;/h3&gt;

&lt;p&gt;Because off disables all frost protection. If a window is accidentally left open overnight or the contact gets stuck, the room cools down without limit ‚Äî on an exterior wall a burst pipe is the worst-case outcome. building_protection holds a safety minimum (~7 ¬∞C) but doesn't heat against the open window. That's the entire point of the exercise.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my thermostat doesn't have a building_protection preset?
&lt;/h3&gt;

&lt;p&gt;Then check the climate entity's more-info dialog for which preset_modes it reports. On some KNX actuators the frost guard is called "frost", or it's done via a low target temperature with climate.set_temperature instead of a preset. The trigger logic stays identical ‚Äî you only swap the action in the disable block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I really need two automations per room?
&lt;/h3&gt;

&lt;p&gt;Not strictly ‚Äî you can also solve it in a single automation with two triggers and a choose block. I deliberately use two separate ones because they're instantly readable in the automation UI ("Heizung Buero Deaktivieren/Aktivieren") and can each be disabled individually when I'm testing something in one room. For a maintainable setup the small redundancy is worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I lose heating energy during airing because of the 5-second delay?
&lt;/h3&gt;

&lt;p&gt;Practically none. In five seconds a radiator gives off barely measurable heat, and the inertia of a water-based system is far larger anyway. Those five seconds buy you the reliability that not every rattle and bus glitch triggers a mode switch ‚Äî an excellent trade.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>knx</category>
      <category>automation</category>
      <category>smarthome</category>
    </item>
    <item>
      <title>A Robust Modbus Proxy: Reconnect, Stale-Cache Detection and Timeouts Done Right</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 10 Jul 2026 13:26:12 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/a-robust-modbus-proxy-reconnect-stale-cache-detection-and-timeouts-done-right-149b</link>
      <guid>https://dev.to/cloudapp_dev/a-robust-modbus-proxy-reconnect-stale-cache-detection-and-timeouts-done-right-149b</guid>
      <description>&lt;p&gt;A self-built Modbus cache proxy runs for weeks without complaint in summer — until the first night the inverter shuts down, or the first firmware reboot of the SDongle. That's exactly when you find out whether you built a proxy or a time bomb. My first attempt was naive: poll, cache, serve. It worked perfectly during the day. At night, when the SUN2000 went to sleep, the poll loop hung in a read that never returned — and for hours the proxy silently served the last daytime values as if nothing was wrong.&lt;/p&gt;

&lt;p&gt;That's the dangerous failure: not the crash (you notice that), but the proxy that keeps running and serves stale data while nobody is the wiser. This post is the reliability playbook that turned my proxy into something I trust. I build the proxy itself in the &lt;a href="https://www.cloudapp.dev/en-US/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;Modbus caching basics post&lt;/a&gt; — here it's purely about the robustness underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode no beginner guide covers
&lt;/h2&gt;

&lt;p&gt;Tutorials show the happy path: connect to the SDongle, read registers, done. What they leave out: the SDongle is slow, opinionated hardware. It drops the connection at night, it needs a minute after a firmware reboot before it answers again, and it can't handle reads fired back-to-back. A naive &lt;strong&gt;asyncio&lt;/strong&gt; read without a timeout then blocks forever, and your cache freezes on its last value. HA dutifully keeps showing numbers — they're just no longer true.&lt;/p&gt;

&lt;p&gt;The fix has three pillars. First, every single read gets a hard timeout and the spacing the SDongle needs. Second, the poll loop retries fast and then backs off, instead of hanging. Third — and this is the part almost nobody builds — the proxy makes its own staleness visible, so Home Assistant can alert on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — per-batch timeout and the 50 ms spacing
&lt;/h2&gt;

&lt;p&gt;The inverter is polled in register batches. Each batch gets its own timeout (in the &lt;strong&gt;read_batch&lt;/strong&gt; helper, as an &lt;strong&gt;asyncio.wait_for&lt;/strong&gt;), and there's a 50-millisecond pause between two reads — the SDongle is too slow to answer reads in quick succession and punishes haste with timeouts. The crucial bit is distinguishing the two exceptions: a single &lt;strong&gt;TimeoutError&lt;/strong&gt; is survivable (one batch is missing this round), but any other exception means the connection is probably dead — then we leave the loop immediately via &lt;strong&gt;break&lt;/strong&gt; instead of hammering a dead socket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REGISTER_BATCHES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;read_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;cache_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;register_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 50ms between reads — the SDongle is slow
&lt;/span&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fail_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fail_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# connection probably dead, leave the loop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;break&lt;/strong&gt; is the heart of it: a single timeout must not abort the batch pass — otherwise every passing glitch loses you half the register set. But a real &lt;strong&gt;ConnectionError&lt;/strong&gt; or a torn-down stream has to end the pass, so the outer loop can build a fresh reconnect instead of reading blindly into the void.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — fast-retry-then-backoff and the stale-cache warning
&lt;/h2&gt;

&lt;p&gt;The outer reader loop decides how often to reconnect. The logic is deliberately asymmetric: after a successful poll we wait the normal &lt;strong&gt;POLL_INTERVAL&lt;/strong&gt; (10 s). When a poll fails, we retry fast — capped at 10 s, so a brief hiccup is bridged in seconds without flooding the SDongle with reconnect attempts. And then the most important part: when the cache is older than 120 seconds, we write an explicit &lt;strong&gt;Cache stale&lt;/strong&gt; warning to the log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;retry_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;read_sdongle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;last_update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;retry_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;POLL_INTERVAL&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;retry_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_update&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_update&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache stale for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one log line is the difference between a proxy that lies and one that's honest. It makes staleness observable. In Home Assistant you can catch it on the consumer side: a sensor that hasn't updated for minutes flips to &lt;strong&gt;unavailable&lt;/strong&gt; — and you can hang a push notification off that, just like any other anomaly (see the pattern in the &lt;a href="https://www.cloudapp.dev/en-US/home-assistant-pv-string-fault-detection" rel="noopener noreferrer"&gt;PV string anomaly post&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — client idle timeout against dead connections
&lt;/h2&gt;

&lt;p&gt;The other side of the proxy is the clients (HA, evcc, a second dashboard). Without an idle timeout, dead client connections pile up — an HA restart, a crashed container, and the old socket sits open forever. Every Modbus request starts with a 7-byte MBAP header; we read it with a 60-second timeout. If nothing arrives, or fewer than 7 bytes, the client is gone and we close the connection cleanly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# Client disconnected
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;60 seconds is generous — HA typically polls every 30–60 s, so a healthy connection easily outlives the timeout. A dead connection, on the other hand, never sends another header and gets reaped within a minute at most, instead of holding a slot and memory hostage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration — never real LAN IPs
&lt;/h2&gt;

&lt;p&gt;All the values that change live at the top in a config block. Put in your own SDongle address — a DHCP-reserved LAN address is ideal so it doesn't drift. The port is 502 or 6607 depending on firmware. Never publish your real LAN IP in a gist or forum post; use placeholders, like here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Configuration (mit eigenen Werten ersetzen)
&lt;/span&gt;&lt;span class="n"&gt;SDONGLE_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_SDONGLE_IP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# z.B. eine DHCP-reservierte Adresse im LAN
&lt;/span&gt;&lt;span class="n"&gt;SDONGLE_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;                 &lt;span class="c1"&gt;# oder 6607, je nach Firmware
&lt;/span&gt;&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="n"&gt;SERVER_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;SERVER_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5502&lt;/span&gt;
&lt;span class="n"&gt;POLL_INTERVAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;  &lt;span class="c1"&gt;# seconds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Numbers from long-term operation
&lt;/h2&gt;

&lt;p&gt;These values were tuned over months of real operation with a Huawei SUN2000 SDongle. 50 ms spacing: below it timeouts piled up, above it the poll got sluggish. 10 s poll interval: fine enough for PV data and gentle on the SDongle. 120 s stale threshold: two missed polls plus headroom. 60 s client idle: covers any sane HA scan interval. Tune them to your hardware, but start here.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why not just use the ready-made ha-modbusproxy add-on?
&lt;/h3&gt;

&lt;p&gt;You can — the add-on is good and takes the work off your hands. This post is for those who built their own proxy (or want to understand what happens underneath) and need to control the reliability layer themselves. The failure modes and thresholds here apply conceptually to any caching proxy, self-built or add-on alike.&lt;/p&gt;

&lt;h3&gt;
  
  
  My cache still freezes sometimes — why?
&lt;/h3&gt;

&lt;p&gt;Almost always the missing &lt;strong&gt;break&lt;/strong&gt; in the batch loop: if the connection dies but a read throws only a TimeoutError instead of a ConnectionError, the loop keeps running against the dead socket and the outer loop never rebuilds. Make sure a real connection failure leaves the batch pass. Second suspect: a &lt;strong&gt;read&lt;/strong&gt; with no &lt;strong&gt;asyncio.wait_for&lt;/strong&gt; at all — a single read without a timeout is enough to block the whole loop forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I alert on a stale cache in Home Assistant?
&lt;/h3&gt;

&lt;p&gt;Easiest is via the sensor staleness itself: a Modbus sensor that stops getting fresh values goes &lt;strong&gt;unavailable&lt;/strong&gt; after a few missed scans. Hang an automation off that with a &lt;strong&gt;state&lt;/strong&gt; trigger to &lt;strong&gt;unavailable&lt;/strong&gt; and a &lt;strong&gt;for&lt;/strong&gt; duration of a few minutes to ride out brief dropouts. If you want it more explicit, build a template binary sensor that checks the age of the last update against a threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which SDongle port is correct — 502 or 6607?
&lt;/h3&gt;

&lt;p&gt;Depends on the SDongle firmware. Older firmware often speaks the standard Modbus port 502; newer ones moved Modbus TCP to 6607 in places, or require you to enable it in the FusionSolar app first. Try 502 first; if the connect is refused outright, use 6607. If nothing happens at all, Modbus TCP on the dongle is probably still disabled.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>python</category>
      <category>iot</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
