<?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>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>
    <item>
      <title>Solar Surplus into Hot Water: the AC·THOR in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 08 Jul 2026 17:54:28 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/solar-surplus-into-hot-water-the-acthor-in-home-assistant-m5d</link>
      <guid>https://dev.to/cloudapp_dev/solar-surplus-into-hot-water-the-acthor-in-home-assistant-m5d</guid>
      <description>&lt;p&gt;On a sunny afternoon my roof makes more power than the house can swallow. The battery fills, the inverter would rather give the rest to the grid for almost nothing, and meanwhile there's a 200-litre tank of cold water sitting in the cellar. The obvious move is to dump that surplus into the immersion heater and bank it as hot water. The device that does it is a &lt;strong&gt;my-PV AC·THOR 9s&lt;/strong&gt; — a resistive PV-surplus diverter that modulates power into the boiler element instead of letting it spill to the grid. This post is the story of getting it under &lt;strong&gt;Home Assistant&lt;/strong&gt; control, and the wall I hit doing it.&lt;/p&gt;

&lt;p&gt;It's Part 03 of how I'm self-hosting my house. &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;Part 01 put Home Assistant on a Docker host&lt;/a&gt;, Part 02 added HACS, and the inverter side — a &lt;strong&gt;Huawei SUN2000-8KTL-M1&lt;/strong&gt; read over Modbus TCP through a Huawei SDongle, plus a battery whose charge and discharge I track — was already prior art. Surplus, for the AC·THOR, is simply what's left after house load and battery charging. So the hard part wasn't the energy logic. It was the control plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan that should have worked
&lt;/h2&gt;

&lt;p&gt;The AC·THOR speaks Modbus TCP, and my whole energy stack already lives on Modbus, so the design wrote itself. Two registers do everything: &lt;strong&gt;register 5000&lt;/strong&gt; is heating enable/disable (0 or 1), and &lt;strong&gt;register 5006&lt;/strong&gt; is the power limit in watts. The device has two useful modes — an Eco setting at &lt;strong&gt;1500 W&lt;/strong&gt; for pure-solar heating, and Full Power at &lt;strong&gt;3000 W&lt;/strong&gt;, which is also its hardware maximum. You can switch between them live while it's heating.&lt;/p&gt;

&lt;p&gt;In Home Assistant I modelled that as a small control surface: an &lt;strong&gt;input_select&lt;/strong&gt; for the power mode, an &lt;strong&gt;input_boolean&lt;/strong&gt; for the heating-active flag, and a dashboard card — &lt;strong&gt;AC·THOR Steuerung&lt;/strong&gt; — with plain Start and Stop buttons. Behind the buttons, a couple of mbpoll-style writes: poke 5000 to enable, set 5006 to 1500 or 3000. I tested the reads first to make sure I had the right slave and port. Boiler temperature came back clean on &lt;strong&gt;holding register 1001&lt;/strong&gt; (int16, in °C), polled every 30 seconds. Everything looked ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall: writes refused, reads fine
&lt;/h2&gt;

&lt;p&gt;Then I sent the first write, and the AC·THOR slammed the door. Connection refused — not a timeout, not a bad-value error, a flat refusal at the device level. I checked the slave id, the register, the byte order, the function code. None of it mattered. The thing simply does not accept Modbus &lt;strong&gt;write&lt;/strong&gt; operations. What made it genuinely confusing is that the very same connection happily answers &lt;strong&gt;reads&lt;/strong&gt;: register 1001 kept returning boiler temperature every 30 seconds without complaint, on the same host and port, while every write bounced.&lt;/p&gt;

&lt;p&gt;I'll be honest, that stung. I'd built the whole control path on the assumption that a device exposing Modbus exposes it both ways. It doesn't here, and that's a deliberate choice on my-PV's side — the registers are real, they're just read-only over local Modbus. So the switch-based, all-local design I was proud of was dead for control. The boiler-temperature monitoring, though, survives untouched, because that was only ever a read.&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;# Modbus read still works — boiler temp stays local; only WRITES are refused&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;ac_thor&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;LOCAL_IP&amp;gt;&lt;/span&gt;        &lt;span class="c1"&gt;# redacted — never publish your LAN address&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
  &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;timeout&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;sensors&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;Boiler&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Temperature"&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1001&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int16&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;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pivot: read local, write cloud
&lt;/h2&gt;

&lt;p&gt;The path that actually worked is the my-PV Cloud API. Every AC·THOR phones home, and that cloud exposes the same configuration the local Modbus writes would have touched: a &lt;strong&gt;GET /data&lt;/strong&gt; for live monitoring and a &lt;strong&gt;GET/PUT /setup&lt;/strong&gt; to read and change config. So the split I shipped is read-local, write-cloud — boiler temperature stays on local Modbus (register 1001, every 30 s, no internet dependency), and all control goes out through the cloud.&lt;/p&gt;

&lt;p&gt;I resent it a little. Depending on a vendor cloud to turn my own immersion heater on is exactly the kind of dependency I self-host to avoid, and if their API goes down my morning shower is at the mercy of someone else's uptime. But it's the split that actually works today, and it unlocks something the local-only design never could: proper time-aware behaviour.&lt;/p&gt;

&lt;p&gt;There's one gotcha worth a sentence. A PUT to /setup returns &lt;strong&gt;ok&lt;/strong&gt; instantly, but the device doesn't apply it for another &lt;strong&gt;4 to 6 seconds&lt;/strong&gt;. If you immediately GET /setup back to confirm, you'll read the old values and think your write was ignored. It wasn't — you just have to wait, then poll.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cloud control actually buys: free heat by day, a warm tank by morning
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't on/off — it's two temperature targets and a time window. The AC·THOR carries a &lt;strong&gt;max solar target&lt;/strong&gt; (ww1target) and a lower &lt;strong&gt;boost target&lt;/strong&gt; (ww1boost). By day I let surplus push the tank all the way to a high solar cap — &lt;strong&gt;65 °C&lt;/strong&gt; — because that heat is free; every degree the sun banks is a degree I don't buy at night. Temperatures are encoded in tenths of a degree, so 650 means 65.0 °C and 400 means 40.0 °C, which trips you up exactly once.&lt;/p&gt;

&lt;p&gt;The morning is handled by the cloud's assurance mode — &lt;strong&gt;Sicherstellungsmodus&lt;/strong&gt;, parameter bstmode. I set a boost window from &lt;strong&gt;01:00 to 08:00&lt;/strong&gt; (bstton1=1, bsttof1=8) with a modest &lt;strong&gt;40 °C&lt;/strong&gt; needed-target, so the tank is guaranteed warm by the time anyone showers, regardless of whether the night was sunny. A second optional window exists if you want it. Daytime stays solar-only up to the high cap; the night just guarantees a floor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local Modbus writes are refused — drive config via the my-PV Cloud API instead.&lt;/span&gt;
&lt;span class="c"&gt;# Set a 40°C morning-boost window 01:00–08:00 (values are tenths of a degree: 400 = 40.0°C)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"https://api.my-pv.com/api/v1/device/&amp;lt;SERIAL&amp;gt;/setup"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;REDACTED_TOKEN&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"bstmode": 1, "ww1boost": 400, "bstton1": 1, "bsttof1": 8}'&lt;/span&gt;
&lt;span class="c"&gt;# API replies "ok" instantly; the device applies it ~4–6 s later, so poll GET /setup to confirm.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Seeing the split that justifies the whole thing
&lt;/h2&gt;

&lt;p&gt;The reason any of this is worth the trouble shows up in two sensors: &lt;strong&gt;solar heating power&lt;/strong&gt; and &lt;strong&gt;grid heating power&lt;/strong&gt;, reported separately. The whole point of surplus diversion is that the first number is large and the second is zero — heat from sun I'd otherwise have exported, not from the meter. Alongside those I watch the current power limit and the boiler temperature, and the inverter's own Modbus reads give PV yield, grid export and import, and the battery's daily charge and discharge. With all of that in one place I can actually watch surplus turn into hot water in real time.&lt;/p&gt;

&lt;p&gt;And one quiet safety note that made me trust the setup: whatever power or target I command, the AC·THOR enforces its own internal thermal and safety limits anyway. Software requests, hardware protects. That separation is exactly what you want for a 3 kW element in a water tank — my automation can be wrong without being dangerous.&lt;/p&gt;

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

&lt;p&gt;The hot-water diverter is one slice of a whole-home energy setup, not a toy. The same Home Assistant install runs per-room radiator thermostats that drop to a setback preset when a window contact opens, and a scheduled house-wide &lt;strong&gt;Nachtmodus&lt;/strong&gt; at 21:00 with &lt;strong&gt;Tagmodus&lt;/strong&gt; back at 05:00 across a dozen-ish zones. The hot water just happens to be the one corner where the obvious local path was a dead end and I had to swallow a cloud dependency to ship.&lt;/p&gt;

&lt;p&gt;If I had to compress the lesson: a device speaking a protocol doesn't promise it speaks every verb of it. Read worked, write didn't, and the fix wasn't to fight the device — it was to keep the cheap, local, no-internet read where it belonged and move only the writes to the one transport the vendor actually allows. Not the architecture I wanted. The one that put hot water in the tank by morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The automation behind it
&lt;/h2&gt;

&lt;p&gt;Once the boiler hits 55 °C I switch the AC·THOR boost back off automatically — with a push confirmation to my phone. This is the exact automation I run:&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;# Stop the boost as soon as the boiler hits the 55 °C target&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;AC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;THOR:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;boost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;55&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;°C"&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;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.boiler_temperature&lt;/span&gt;      &lt;span class="c1"&gt;# your own sensor name&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;55&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;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="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;shell_command.acthor_disable_boost&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;notify.mobile_app&lt;/span&gt;                 &lt;span class="c1"&gt;# generic — no device/person name&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;title&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;AC·THOR&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reached"&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Boost&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;at&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;states('sensor.boiler_temperature')&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;°C"&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;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmkdcu2ihmjlbkpmx8n8t.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmkdcu2ihmjlbkpmx8n8t.jpg"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My PV / boiler dashboard in Home Assistant — live PV power, battery SOC and boiler temperature at a glance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>solar</category>
      <category>energy</category>
      <category>iot</category>
    </item>
    <item>
      <title>Modbus Writes Through the Cache Proxy in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 03 Jul 2026 13:13:41 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/modbus-writes-through-the-cache-proxy-in-home-assistant-1ioe</link>
      <guid>https://dev.to/cloudapp_dev/modbus-writes-through-the-cache-proxy-in-home-assistant-1ioe</guid>
      <description>&lt;p&gt;My Huawei SDongle allows exactly one Modbus connection at a time. That's why I run a small caching proxy in front of it that holds the connection exclusively and answers all reads from cache — I described it in detail in the &lt;a href="https://www.cloudapp.dev/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;caching proxy post&lt;/a&gt;. It works beautifully as long as you only read. Then Tibber came into the picture, I wanted to force-charge the battery on negative electricity prices and set the export limit dynamically — and suddenly I needed writes.&lt;/p&gt;

&lt;p&gt;Here's the non-obvious catch: &lt;strong&gt;a cache only solves reads.&lt;/strong&gt; A write has no business sitting in a cache — it has to actually reach the inverter, otherwise nothing changes physically. So the proxy needs a special case: read from cache, pass writes straight through, and do it all without breaking the single-connection invariant. That write-through path is exactly what I'll show here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a write must not go into the cache
&lt;/h2&gt;

&lt;p&gt;The caching proxy gets all its mileage from the fact that read registers change slowly — battery level, string currents, daily yield. Those you can happily cache for a few seconds and serve to several clients at once. A write command is the exact opposite: it's a side effect, not a value. "Set register 47075 to forced-charge mode" has to land on the device, or the command was a no-op.&lt;/p&gt;

&lt;p&gt;Modbus distinguishes this cleanly via the function code. Reads are FC3 (Read Holding Registers) or FC4 — those go into the cache. The classic for writing a single register is &lt;strong&gt;FC6 (Write Single Register)&lt;/strong&gt;, and that's exactly what the proxy intercepts and handles differently: pass through instead of cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting and forwarding FC6
&lt;/h2&gt;

&lt;p&gt;In the proxy's request handler I check the function code from the PDU. If it's a 6 and the PDU is long enough, I unpack the register address and value and forward the write straight to the SDongle. If it succeeds, I echo the standard write response (FC6 replies with the address and value mirrored back). If it fails, I return a proper Modbus exception instead of leaving the client hanging:&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;elif&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="ow"&gt;and&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;pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;write_ok&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;forward_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_value&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;write_ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;BHH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# echo
&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;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;BB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 0x80|fc, code 4 = device failure
&lt;/span&gt;    &lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&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;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter. The &lt;strong&gt;transaction ID&lt;/strong&gt; (tx_id) from the MBAP header I return unchanged — the client matches responses by it, and a wrong value leads to timeouts. And the error response follows the Modbus convention: function code with the top bit set (&lt;strong&gt;fc + 0x80&lt;/strong&gt;) plus an exception code; the 4 means "Slave Device Failure". That way Home Assistant knows the write attempt failed instead of blindly waiting for a reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forwarding the write on a short-lived connection
&lt;/h2&gt;

&lt;p&gt;Now the core that preserves the single-connection invariant. The proxy keeps its one persistent connection for the polling loop. For a write I open a &lt;strong&gt;separate, short-lived&lt;/strong&gt; connection to the SDongle, send exactly one FC6 frame, read the reply and close again immediately. Because the write is so brief, it practically never collides with the poll cycle — and if it does, this is the spot where a mutex around SDongle access belongs (more on that in a later post).&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;# SDONGLE_HOST / SDONGLE_PORT come from your config, e.g.
# SDONGLE_HOST = "10.0.0.x"   # your inverter's SDongle
# SDONGLE_PORT = 502
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_value&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="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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SDONGLE_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SDONGLE_PORT&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;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHBBHH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_value&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="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&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;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;12&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;10&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="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&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;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;struct.pack("&amp;gt;HHHBBHH", ...)&lt;/strong&gt; builds a complete Modbus TCP frame: MBAP header (transaction ID 0, protocol ID 0, length 6), then unit ID, function code 6, register address and value. The 10-second timeouts are generous — an SDongle normally answers a write in milliseconds. A valid FC6 response is 12 bytes; if I get fewer, the write counts as failed and the caller above sends the exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejecting unknown function codes cleanly
&lt;/h2&gt;

&lt;p&gt;There are more function codes than FC3, FC4 and FC6 — FC16 (Write Multiple Registers), FC1, FC2 and so on. When a client sends a code the proxy doesn't implement, the worst reaction is to not reply at all. Then the client blocks until timeout and the whole poll loop stalls. The right answer is the Modbus exception &lt;strong&gt;"Illegal Function" (code 1)&lt;/strong&gt; — the client knows immediately and moves on:&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;BB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 0x80|fc, code 1 = illegal function
&lt;/span&gt;    &lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&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;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same exception mechanic as the device-failure case, just with code 1 instead of 4. A well-behaved Modbus proxy never leaves a request unanswered — either the real result comes back or a defined error, but never silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually lets you control
&lt;/h2&gt;

&lt;p&gt;With the write-through path I control the battery directly from Home Assistant automations. The most interesting use is the dynamic tariff: when the Tibber price drops below a threshold I write the forced-charge mode, and when it rises again I switch back to self-consumption. To see whether the command actually took, I watch sensors like &lt;strong&gt;sensor.my_pv_batteriestand&lt;/strong&gt; plus &lt;strong&gt;sensor.batterie_gesamt_ladung&lt;/strong&gt; and &lt;strong&gt;sensor.batterie_gesamt_entladung&lt;/strong&gt;. How to measure self-consumption and autarky cleanly is covered in the &lt;a href="https://www.cloudapp.dev/home-assistant-pv-self-consumption-autarky-sensors" rel="noopener noreferrer"&gt;self-consumption post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Important: write registers and their allowed values are strictly vendor-specific and partly dangerous (you can put a system into a nonsensical state). Take the concepts here, but look up the exact registers and permitted values in your own inverter's Modbus document — and test with harmless registers before flipping charge modes.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why not just cache writes too?
&lt;/h3&gt;

&lt;p&gt;Because a write isn't a value, it's a command with a physical side effect. "Writing into the cache" would mean the command never reaches the device — the battery doesn't charge, the power limit doesn't change. Reads can be cached because they're idempotent; writes always have to be passed through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a separate connection for the write — don't I already have one open?
&lt;/h3&gt;

&lt;p&gt;The persistent connection is occupied by the polling loop. A short-lived extra connection just for the write keeps the logic simple and the write frame out of the read-cache path. Because the write is done in milliseconds, the SDongle's "one connection at a time" rule stays practically intact. For real parallel access, a mutex belongs at this spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  What do exception codes 1 and 4 mean?
&lt;/h3&gt;

&lt;p&gt;They are standard Modbus exception codes. Code 1 is "Illegal Function" — the function code isn't supported. Code 4 is "Slave Device Failure" — the command was valid but the device couldn't execute it. Both are signalled with the top bit set in the function code (fc + 0x80), so the client recognizes the response as an error immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with FC16 for multiple registers?
&lt;/h3&gt;

&lt;p&gt;The framework shown only handles FC6 (single register), because that's enough for charge mode and power limit. FC16 (Write Multiple Registers) can be added on the same pattern: unpack the PDU differently, build the frame with the matching byte count, forward, echo. Until then an FC16 request lands in the else branch and gets a clean "Illegal Function" back instead of blocking the client.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>python</category>
      <category>iot</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Pull a Cloud API into Home Assistant via REST Sensor</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:44:49 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/pull-a-cloud-api-into-home-assistant-via-rest-sensor-51i0</link>
      <guid>https://dev.to/cloudapp_dev/pull-a-cloud-api-into-home-assistant-via-rest-sensor-51i0</guid>
      <description>&lt;p&gt;Sooner or later almost every Home Assistant setup has a device with no native integration. A wallbox, an inverter, in my case a my-PV hot-water heating element — the vendor app shows every value nicely, but nothing of it appears in HA. What there almost always is, though, is a cloud or local JSON API: an HTTP endpoint that, on a GET request, returns a JSON object with all the readings. That is exactly where the built-in REST sensor comes in.&lt;/p&gt;

&lt;p&gt;The trick the official docs cover only briefly: from a single JSON endpoint you can build a whole dozen clean HA sensors — one per field, each with its own name, unit and device_class. In this post I show the pattern on my real my-PV cloud API (which has no native HA integration), but every single line transfers to any REST/JSON API you like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the REST sensor does
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;platform: rest&lt;/strong&gt; sensor polls a URL on a fixed interval, gets JSON back, and turns one field of that JSON into a sensor state. You give it four things: the &lt;strong&gt;resource&lt;/strong&gt; (the URL), authentication via &lt;strong&gt;headers&lt;/strong&gt; if needed, a &lt;strong&gt;value_template&lt;/strong&gt; that pulls the field you want out of the JSON, and a &lt;strong&gt;scan_interval&lt;/strong&gt; that sets how often it polls. At its core, that's all it takes.&lt;/p&gt;

&lt;p&gt;One thing to understand: each REST sensor makes its own HTTP request. That matters as soon as you want several fields from the same endpoint — more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first sensor: one field from the API
&lt;/h2&gt;

&lt;p&gt;Let's start with the simplest case: a single value from the my-PV cloud API. The API answers the /data endpoint with a JSON object that contains, among others, &lt;strong&gt;surplus&lt;/strong&gt; (surplus power). The sensor pulls exactly that field out.&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;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rest&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;My-PV Surplus Power&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.my-pv.com/api/v1/device/&amp;lt;device-id&amp;gt;/data"&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!secret&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;my_pv_api_token"&lt;/span&gt;
  &lt;span class="na"&gt;value_template&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;value_json.surplus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&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;W"&lt;/span&gt;
  &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen here. &lt;strong&gt;value_json&lt;/strong&gt; is the parsed JSON response — with &lt;strong&gt;value_json.surplus&lt;/strong&gt; you access the field, just like in any other HA template. You never store the token in clear text; you keep it as &lt;strong&gt;!secret my_pv_api_token&lt;/strong&gt; in your secrets.yaml and HA sets it as the Authorization header. And &lt;strong&gt;scan_interval: 60&lt;/strong&gt; polls once a minute — deliberately conservative, so you don't run into the cloud's rate limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  One endpoint, many sensors
&lt;/h2&gt;

&lt;p&gt;Now the actual pattern: the same /data response carries more than just surplus — a whole bundle of fields, including boiler temperature, battery state of charge, house consumption. For each you define another REST sensor with the same resource but a different value_template. That's how one endpoint becomes a whole dashboard.&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;value_template&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;value_json.temp1&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;float&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;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;   &lt;span class="c1"&gt;# boiler temp in °C&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="na"&gt;value_template&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;value_json.m2soc&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;                 &lt;span class="c1"&gt;# battery state of charge %&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="na"&gt;value_template&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;value_json.m0sum&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;                 &lt;span class="c1"&gt;# house consumption W&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the first entry: &lt;strong&gt;temp1 | float / 10&lt;/strong&gt;. This is the single most common gotcha with vendor APIs — many return temperatures as tenths of a degree in integer form (550 instead of 55.0). With &lt;strong&gt;| float / 10&lt;/strong&gt; you scale that to the correct value right in the template. You know the counterpart from the Modbus world, where the &lt;a href="https://www.cloudapp.dev/home-assistant-huawei-sun2000-modbus-registers" rel="noopener noreferrer"&gt;scale factor does the same job&lt;/a&gt; — with register sensors HA scales for you, with REST you do it yourself in the value_template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making states readable: 0/1 to On/Off
&lt;/h2&gt;

&lt;p&gt;Some fields aren't measurements but status flags. The my-PV API has a second endpoint, /setup, that returns the configuration — including &lt;strong&gt;bstmode&lt;/strong&gt;, the boost status, as 0 or 1. With a small if in the value_template you turn that into a plain-text sensor that shows "On" or "Off" instead of a bare number.&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;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rest&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;My-PV AC THOR Boost Status&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.my-pv.com/api/v1/device/&amp;lt;device-id&amp;gt;/setup"&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!secret&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;my_pv_api_token"&lt;/span&gt;
  &lt;span class="na"&gt;value_template&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;'On'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;value_json.setup.bstmode&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;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;else&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;}}"&lt;/span&gt;
  &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are new here. First, &lt;strong&gt;value_json.setup.bstmode&lt;/strong&gt; — the response is nested, the value you want sits under a setup object, so you just extend the path with a dot. Second: if you query both /data and /setup, those are two different URLs and therefore two separate REST sensors, each with its own scan_interval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling interval and rate limits
&lt;/h2&gt;

&lt;p&gt;The scan_interval is the most important dial for being polite to the cloud. Each sensor polls independently — five sensors on the same endpoint at scan_interval 30 is ten requests per minute. Few vendors publish their rate limits; when in doubt, start at 60 seconds and only go lower if you genuinely need the value more often. For values that barely change (daily yield, configuration), even 300 seconds is perfectly fine.&lt;/p&gt;

&lt;p&gt;If you need several fields from exactly the same endpoint and want to cut request load, look at the &lt;strong&gt;rest:&lt;/strong&gt; platform (rather than &lt;strong&gt;platform: rest&lt;/strong&gt;): there you define the endpoint once and hang several &lt;strong&gt;sensor&lt;/strong&gt; blocks with their own value_template beneath it — one request, many sensors. The single-sensor pattern shown here is the better starting point, because each sensor is completely self-contained.&lt;/p&gt;

&lt;h2&gt;
  
  
  From reading to writing
&lt;/h2&gt;

&lt;p&gt;The REST sensor is read-only. As soon as you want to actually &lt;strong&gt;control&lt;/strong&gt; something over the same cloud API — in my setup, turning the AC·THOR heating element on and off, because its Modbus write access is blocked at the device — you need the writing counterpart: a shell_command with curl and PUT instead of GET. How I solved that for the my-PV control I cover in a separate post. The REST sensor provides the feedback for it: write with PUT, then verify with the /setup sensor that the value actually landed.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What exactly is value_json?
&lt;/h3&gt;

&lt;p&gt;value_json is the already-parsed JSON response of the endpoint, which HA hands you inside the value_template. You navigate it with dot notation: value_json.surplus for a top-level field, value_json.setup.bstmode for a nested one. If the API returns an array, you index with value_json[0]. If you don't know what the response looks like, hit the endpoint once with curl and inspect the JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  My sensor shows "unknown" — what's wrong?
&lt;/h3&gt;

&lt;p&gt;Almost always one of three things: the value_template path doesn't match the real JSON structure (often a forgotten nested object), authentication fails (token wrong, or expected as Bearer instead of a bare value), or the API doesn't return JSON for that call at all. As a test, set value_template to {{ value }} instead of {{ value_json.field }} — you then see the raw response in the state and immediately know whether it's an auth or a path problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I scale raw values correctly?
&lt;/h3&gt;

&lt;p&gt;In the value_template, with the normal Jinja filters. Divide tenths of a degree by 10 ({{ value_json.temp1 | float / 10 }}), milliwatts by 1000, and so on. The important bit is the | float (or | int): it cleanly casts the value to a number before you do math, otherwise some APIs give you string concatenation instead of a division. Set device_class and unit_of_measurement to match, so the value is sorted correctly in the dashboard and in statistics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work for a local API without a cloud?
&lt;/h3&gt;

&lt;p&gt;Yes, identically. resource then simply points at the device's local address (e.g. http:///status) instead of a cloud host. For local devices without authentication you drop the headers block; everything else is the same. That's exactly what makes the REST sensor so universal — vendor cloud or a small HTTP server on your LAN, the pattern stays the same.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>api</category>
      <category>tutorial</category>
      <category>yaml</category>
    </item>
    <item>
      <title>Self-Consumption &amp; Autarky: PV Metrics in Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 30 Jun 2026 07:27:27 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/self-consumption-autarky-pv-metrics-in-home-assistant-4a9</link>
      <guid>https://dev.to/cloudapp_dev/self-consumption-autarky-pv-metrics-in-home-assistant-4a9</guid>
      <description>&lt;p&gt;The number I actually care about with a PV system isn't how much the panels produced. It's how much of it I kept. The inverter's cloud portal shows me a self-consumption percentage and an autarky percentage on a nice dashboard, and for a long time I treated those two figures as the scoreboard for the whole house. The problem: they live in someone else's cloud, I can't chart their history the way I want, and I can't alert on them. So I rebuilt them in Home Assistant. The catch I didn't expect is that Home Assistant doesn't hand you any of these numbers either.&lt;/p&gt;

&lt;p&gt;This is Part 02 of the series. &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;Part 01 covered getting Home Assistant running via Docker&lt;/a&gt; and pulling the inverter in over Modbus. Now that the raw counters are flowing, this post is about turning them into the four metrics I wanted: self-consumption, autarky, battery round-trip efficiency, and a string-imbalance watchdog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Home Assistant gives you counters, not KPIs
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. Home Assistant has an Energy Dashboard, and it's genuinely good at showing you a picture: production, grid import, grid export, battery flow. What it does &lt;strong&gt;not&lt;/strong&gt; expose are the derived percentages. There is no &lt;strong&gt;sensor.self_consumption&lt;/strong&gt; entity. There is no &lt;strong&gt;sensor.autarky&lt;/strong&gt; entity. The Energy Dashboard computes that picture internally and draws it, but it never hands you the numbers back as entities you can chart over a year or trip an automation on.&lt;/p&gt;

&lt;p&gt;My inverter is a Huawei SUN2000, and even its own integration only surfaces the raw lifetime and daily counters — total yield, grid feed-in, grid import, battery charge, battery discharge. Everything else I want is arithmetic on top of those. So I built the metrics myself as Home Assistant &lt;strong&gt;template sensors&lt;/strong&gt; , defined in a &lt;strong&gt;templates.yaml&lt;/strong&gt; file. That distinction matters to me: these aren't values the integration gave me, they're values I derived, which means I get to decide exactly how they're calculated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-consumption and the reconstruction problem
&lt;/h2&gt;

&lt;p&gt;Self-consumption is the easy one. It's the share of what you produced that you actually used yourself instead of exporting: total yield minus grid export, over total yield. My &lt;strong&gt;Eigenverbrauchsquote&lt;/strong&gt; sensor does exactly that and lands around 65.9% — and it matches the number the cloud portal shows, which was a satisfying moment, because it meant I'd correctly reverse-engineered what the cloud was doing. Same logic, just running locally instead of in someone's data centre.&lt;/p&gt;

&lt;p&gt;Autarky is where it gets interesting, and where the honest engineering beat lives. Autarky is the share of your consumption covered by your own production — one minus (grid import over consumption). But Home Assistant never measures household consumption directly. Nobody does, unless you've wired a meter on the main feed. So you reconstruct it from three counters: consumption equals yield, minus what you exported, plus what you pulled from the grid. That reconstructed consumption is what goes in the denominator. My &lt;strong&gt;Autarkiegrad&lt;/strong&gt; sensor sits around 76.9%.&lt;/p&gt;

&lt;p&gt;I want to be clear that this is a reconstruction, not a reading. If your counters drift, your autarky drifts with them. But for a household figure, three counters and a bit of algebra get you a number that's plenty accurate to act on — and it's a number that's now &lt;strong&gt;mine&lt;/strong&gt; , living in my own history, not the inverter vendor'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;# Autarky as a Home Assistant template sensor&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;Autarkiegrad"&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;%"&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mdi:leaf"&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 ertrag = states('sensor.pv_gesamt_ertrag') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set einspeisung = states('sensor.netzeinspeisung_gesamt') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set netzbezug = states('sensor.netzbezug_gesamt') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set verbrauch = ertrag - einspeisung + netzbezug %}&lt;/span&gt;
    &lt;span class="s"&gt;{% if verbrauch &amp;gt; 0 %}&lt;/span&gt;
      &lt;span class="s"&gt;{{ ((1 - netzbezug / verbrauch) * 100) | round(1) }}&lt;/span&gt;
    &lt;span class="s"&gt;{% else %}&lt;/span&gt;
      &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="s"&gt;{% endif %}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entity names are German because they're mine — &lt;strong&gt;Autarkiegrad&lt;/strong&gt; , &lt;strong&gt;Eigenverbrauchsquote&lt;/strong&gt; — and I gave each a fitting icon, &lt;strong&gt;mdi:leaf&lt;/strong&gt; for autarky and &lt;strong&gt;mdi:home-lightning-bolt&lt;/strong&gt; for self-consumption. Small thing, but a leaf next to your autarky number in the dashboard is quietly motivating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Battery health, in two cheap sensors
&lt;/h2&gt;

&lt;p&gt;The battery deserves its own KPIs, and the nice thing is they fall out of counters you already have. Round-trip efficiency is just energy out over energy in: discharge over charge. My &lt;strong&gt;Batterie Effizienz&lt;/strong&gt; sensor reports 97.2%, which is genuinely healthy for a home battery, and because it's a sensor with history I can watch that number slowly degrade over the years instead of finding out the hard way.&lt;/p&gt;

&lt;p&gt;The second one is a full-cycle estimate. I don't have a true cycle counter, so I approximate: total charged kWh divided by 10, on the assumption of roughly 10 kWh usable capacity. It's a proxy, not a precise odometer, but as an equivalent-full-cycles figure it's a perfectly reasonable way to track wear. Cheap to compute, useful to chart.&lt;/p&gt;

&lt;p&gt;There's also a tiny detail I enjoy. My boiler temperature comes off a Modbus register that reports tenths of a degree as a raw integer, so a &lt;strong&gt;Temperatur Boiler Präzise&lt;/strong&gt; sensor just divides the raw value by 10. Nothing clever, but it's the kind of thing you only learn by reading the register table — the hardware speaks in tenths, and you have to scale it back yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The watchdog: catching a sick string before the cloud would
&lt;/h2&gt;

&lt;p&gt;My favourite sensor in the whole file is the string-imbalance detector. A PV array usually has two strings, and in healthy conditions their currents track each other closely. When one drifts well below the other, something is wrong — shading, a dead module, or a loose connector. The cloud portal hides this kind of diagnosis behind its own logic; I wanted it local and visible.&lt;/p&gt;

&lt;p&gt;The sensor, &lt;strong&gt;PV String Differenz&lt;/strong&gt; , computes the percentage difference between the two string currents. The gotcha — and this is the part worth showing — is night-time. At night both string currents fall to near zero, and a naive difference divided by a near-zero max blows up into meaningless noise. So the template only computes when the larger current is above 0.5 A; below that it just reports 0.&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;# String-imbalance detector — note the &amp;gt;0.5 A guard so it isn't noise at night&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;PV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;String&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Differenz"&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;%"&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 s1 = states('sensor.pv_string_1_strom') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set s2 = states('sensor.pv_string_2_strom') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set max_val = [s1, s2] | max %}&lt;/span&gt;
    &lt;span class="s"&gt;{% if max_val &amp;gt; 0.5 %}&lt;/span&gt;
      &lt;span class="s"&gt;{{ ((s1 - s2) | abs / max_val * 100) | round(1) }}&lt;/span&gt;
    &lt;span class="s"&gt;{% else %}&lt;/span&gt;
      &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="s"&gt;{% endif %}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On its own that's just a number. The payoff is the automation behind it. &lt;strong&gt;PV String Anomalie Warnung&lt;/strong&gt; fires when the difference stays above 30% for a sustained 30 minutes — the debounce matters, because a passing cloud will briefly imbalance the strings and you don't want a false alarm every time. It's also guarded to only fire in daylight, when string 1 is actually carrying current above 1 A. And the message it sends me literally spells out the likely causes: shading, a defective module, or a loose connector. That's the same diagnostic the inverter cloud keeps to itself, except now it's a push to my phone and I own the logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line a day, and why it's worth it
&lt;/h2&gt;

&lt;p&gt;The other automation is less dramatic but I read it every single evening. &lt;strong&gt;PV Tagesbericht&lt;/strong&gt; fires once a day at 20:00 and crams the whole scoreboard into one push: the day's yield, battery charge and discharge, self-consumption percentage, autarky percentage, and lifetime yield in MWh. The lifetime figure comes off the raw watt-hour counter divided by 1000 and rounded to two decimals — another small scaling detail, like the boiler.&lt;/p&gt;

&lt;p&gt;I also keep daily mirrors — &lt;strong&gt;Eigenverbrauch Heute&lt;/strong&gt; and &lt;strong&gt;Verbrauch Heute&lt;/strong&gt; — that recompute today's self-consumption and consumption from the daily yield, daily feed-in, and daily grid-import counters, so the daily figures use the same arithmetic as the lifetime ones. Percentages round to one decimal, daily kWh to two, cycles to zero. Tidy enough that the evening push reads like a clean report rather than a dump of floats.&lt;/p&gt;

&lt;p&gt;That's the whole thread of this series, really: own your data, self-host the dashboard. The inverter cloud can show me a self-consumption gauge, but it can't let me alert on a sick string, chart my battery's efficiency over five years, or hand me a single evening line with everything in it. Home Assistant doesn't give you those metrics for free — but once you've reconstructed them from the raw counters, they're sensors like any other: chartable, alertable, and yours. Next in the series, I'll wire these numbers into actually doing something with the surplus.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>solar</category>
      <category>energy</category>
      <category>automation</category>
    </item>
    <item>
      <title>What AI Crawlers Actually Do to a Small Blog: 9 Days of Logs</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Sun, 28 Jun 2026 14:03:43 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/what-ai-crawlers-actually-do-to-a-small-blog-9-days-of-logs-4nf0</link>
      <guid>https://dev.to/cloudapp_dev/what-ai-crawlers-actually-do-to-a-small-blog-9-days-of-logs-4nf0</guid>
      <description>&lt;p&gt;I run a small Home Assistant / self-hosting blog. On a normal day a few dozen humans show up. So when I finally grepped my nginx logs for AI crawlers, the number made me stop: in nine days, AI bots hit the site &lt;strong&gt;18,209 times&lt;/strong&gt;. On a blog this size, the machines reading me now outnumber the people.&lt;/p&gt;

&lt;p&gt;Here's the full breakdown, the things that surprised me, and a few points most &lt;em&gt;"should I block AI bots?"&lt;/em&gt; threads get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw numbers (9 days, one small blog)
&lt;/h2&gt;

&lt;p&gt;Of &lt;strong&gt;348,667&lt;/strong&gt; total requests, &lt;strong&gt;18,209 (5.2%)&lt;/strong&gt; came from AI/LLM user-agents:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;What it actually is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ChatGPT-User&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6,687&lt;/td&gt;
&lt;td&gt;OpenAI — &lt;em&gt;live fetch&lt;/em&gt; when someone asks ChatGPT about a page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytespider&lt;/td&gt;
&lt;td&gt;3,369&lt;/td&gt;
&lt;td&gt;ByteDance / TikTok crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;meta-externalagent&lt;/td&gt;
&lt;td&gt;3,274&lt;/td&gt;
&lt;td&gt;Meta AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazonbot&lt;/td&gt;
&lt;td&gt;1,923&lt;/td&gt;
&lt;td&gt;Amazon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;1,211&lt;/td&gt;
&lt;td&gt;OpenAI search index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;850&lt;/td&gt;
&lt;td&gt;Anthropic — training / index crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;319&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckAssistBot&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;DuckDuckGo AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;172&lt;/td&gt;
&lt;td&gt;OpenAI training crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CCBot&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;Common Crawl (feeds many models)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouBot&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;You.com&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Surprise #1: the #1 "AI bot" isn't a crawler
&lt;/h2&gt;

&lt;p&gt;The biggest source by far — &lt;strong&gt;ChatGPT-User, 6,687 requests&lt;/strong&gt; — isn't crawling to train a model. It's a &lt;em&gt;live fetch&lt;/em&gt;: someone asked ChatGPT a question, ChatGPT decided my page was relevant, and pulled it in real time to answer them. Same story with &lt;code&gt;Perplexity-User&lt;/code&gt; and the other assistant-side fetchers.&lt;/p&gt;

&lt;p&gt;That flips the &lt;em&gt;"should I block it?"&lt;/em&gt; math. ChatGPT-User isn't scraping you — it's a real person, through their assistant, reading your page right now. Block it and you don't stop any training; you just stop showing up in answers and lose the visit. (I can see the other end of it in my analytics: real sessions arriving from &lt;code&gt;claude.ai&lt;/code&gt; and &lt;code&gt;gemini.google.com&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;So the mental model &lt;em&gt;"AI bot = scraper to block"&lt;/em&gt; is wrong for a big chunk of the traffic. There are &lt;strong&gt;training crawlers&lt;/strong&gt; (GPTBot, ClaudeBot, CCBot) and there are &lt;strong&gt;live answer-engine fetchers&lt;/strong&gt; (ChatGPT-User, Perplexity-User, DuckAssistBot). Treating them the same is the mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise #2: robots.txt behaviour is all over the place
&lt;/h2&gt;

&lt;p&gt;I checked who actually requests &lt;code&gt;/robots.txt&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClaudeBot: 233&lt;/strong&gt; — diligent, checks constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PerplexityBot: 56&lt;/strong&gt; — checks in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bytespider: 77&lt;/strong&gt; — does fetch it (whether it &lt;em&gt;honours&lt;/em&gt; the contents is its own reputation).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPTBot: 0&lt;/strong&gt; — didn't pull it once in this window (low volume, granted).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT-User: 0&lt;/strong&gt; — never. And that's &lt;em&gt;correct&lt;/em&gt;: it's user-initiated, like a browser. Browsers don't read robots.txt, and a live fetch on a human's behalf shouldn't either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical upshot: a blanket &lt;code&gt;Disallow&lt;/code&gt; is obeyed by the well-behaved &lt;strong&gt;training crawlers&lt;/strong&gt; and ignored by the &lt;strong&gt;user-fetchers&lt;/strong&gt; — because robots.txt was never meant for them. If your goal is &lt;em&gt;"don't feed training, but keep appearing in answers,"&lt;/em&gt; the default already does roughly the right thing — but only for the bots that honour it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap nobody mentions: these crawlers don't run JavaScript
&lt;/h2&gt;

&lt;p&gt;This is the one that actually cost me, and the reason I ended up building a little tool around it.&lt;/p&gt;

&lt;p&gt;Almost every AI crawler fetches your &lt;strong&gt;raw server HTML and does not execute JavaScript&lt;/strong&gt;. So if your framework injects JSON-LD structured data on the client, or your streaming-SSR setup flushes meta tags into the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; instead of the initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, those signals are &lt;strong&gt;invisible&lt;/strong&gt; to the crawler — even though Google renders them and every SEO browser extension tells you you're fine.&lt;/p&gt;

&lt;p&gt;I only found six pages on my own site doing exactly that, because I built a crawler that deliberately parses the &lt;strong&gt;JS-less view&lt;/strong&gt; and diffs it against the hydrated DOM. Googlebot renders; GPTBot and ClaudeBot mostly don't. If you care about being represented correctly in AI answers, your structured data and metadata have to live in the &lt;strong&gt;server HTML&lt;/strong&gt;, not get painted on after hydration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do with this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't reflexively block "AI bots."&lt;/strong&gt; Separate training crawlers (GPTBot, ClaudeBot, CCBot — robots.txt works) from live answer-fetchers (ChatGPT-User, Perplexity-User — blocking them costs real referrals).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify by ASN / reverse DNS, not the user-agent.&lt;/strong&gt; The UA string is trivially spoofed; a "GPTBot" from a random consumer IP is not GPTBot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put structured data + metadata in the server-rendered HTML.&lt;/strong&gt; If it only appears after JS, the AI crawlers never see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch Bytespider&lt;/strong&gt; if load is a concern — it's the most aggressive of the genuine crawlers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a blog with a few dozen human readers a day, AI crawlers are now the single largest non-search audience hitting the server. They aren't going away — so it's worth knowing which ones are reading you, and making sure they can actually see what you publish.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The JS-less-view crawler I used is open-source (&lt;a href="https://github.com/lireking/seo-geo-audit" rel="noopener noreferrer"&gt;seo-geo-audit&lt;/a&gt;) — it flags exactly this gap, plus the usual SEO checks, in plain Node with no paid dependencies.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Read the Huawei SUN2000 in Home Assistant Without a Custom Integration: Native Modbus YAML</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 26 Jun 2026 12:29:36 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/read-the-huawei-sun2000-in-home-assistant-without-a-custom-integration-native-modbus-yaml-2hh9</link>
      <guid>https://dev.to/cloudapp_dev/read-the-huawei-sun2000-in-home-assistant-without-a-custom-integration-native-modbus-yaml-2hh9</guid>
      <description>&lt;p&gt;Almost every Huawei SUN2000 guide opens with the same line: "install the huawei-solar custom integration." And it works — right up until the next Home Assistant upgrade introduces a breaking change, the integration won't load, and suddenly half your Energy Dashboard is blank. That happened to me twice before I switched to the dependency-free route.&lt;/p&gt;

&lt;p&gt;The SUN2000 speaks Modbus-TCP natively, and Home Assistant ships a built-in &lt;strong&gt;modbus:&lt;/strong&gt; platform. You don't need a custom component — you declare each sensor yourself by its register address. That's a bit more YAML, but nothing breaks on upgrade and you understand exactly where every value comes from. This post gives you the complete register-to-YAML map for PV yield, battery and grid — ready for the Energy Dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why native modbus YAML instead of a custom integration
&lt;/h2&gt;

&lt;p&gt;The custom integration is convenient, but it's an extra dependency between you and your data. Every HA update risks it becoming incompatible, and you can end up two days without energy data until a maintainer catches up. The built-in &lt;strong&gt;modbus:&lt;/strong&gt; platform is a core part of Home Assistant — it's maintained with the core and can't "go orphaned." The price is transparency instead of magic: you write a handful of lines per value with register address, data_type and scale. Those exact lines are what I hand you here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Modbus hub: point at the proxy, not directly at the SDongle
&lt;/h2&gt;

&lt;p&gt;The SUN2000's SDongle allows only &lt;strong&gt;one&lt;/strong&gt; concurrent Modbus connection. If HA, evcc and an AC·THOR all hit it directly, they block each other. That's why my Modbus hub doesn't point at the SDongle IP but at &lt;strong&gt;127.0.0.1:5502&lt;/strong&gt; — the local caching proxy that polls the SDongle once and serves the result to any number of clients. Why and how that's needed is covered 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;. If you have only a single client, you can point host/port straight at the SDongle instead — the sensors underneath stay identical.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_inverter&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5502&lt;/span&gt;
  &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;sensors&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;PV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Cumulative&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Yield"&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;huawei_pv_cumulative_yield&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32106&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uint32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;kWh"&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;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two fields that matter for the Energy Dashboard are &lt;strong&gt;device_class: energy&lt;/strong&gt; and &lt;strong&gt;state_class: total_increasing&lt;/strong&gt;. Without them the sensor won't even show up as a selectable source in the energy configuration. The &lt;strong&gt;delay: 2&lt;/strong&gt; on the hub gives the (slow) SDongle two seconds after connecting before the first read — without it you'll see sporadic timeouts at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Battery: total charge and discharge as uint32
&lt;/h2&gt;

&lt;p&gt;The battery energy registers are cumulative counters — they only go up, which fits &lt;strong&gt;total_increasing&lt;/strong&gt; perfectly. The key here is the data_type &lt;strong&gt;uint32&lt;/strong&gt;: the values quickly exceed the 16-bit range and therefore occupy two consecutive registers (37780 and the following one). HA reads both together when you set data_type uint32 — no second address needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Battery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Charge"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_battery_total_charge&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37780&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uint32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;kWh"&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;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Battery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Discharge"&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;huawei_battery_total_discharge&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37782&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uint32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;kWh"&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;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You enter these two sensors in the Energy Dashboard as "home battery storage" — charge as "energy into the battery," discharge as "energy out of the battery." That lets HA track the battery round-trip balance correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grid: import and export as signed int32
&lt;/h2&gt;

&lt;p&gt;With the grid registers the classic mistake is the data_type. Grid values can come as &lt;strong&gt;signed int32&lt;/strong&gt; depending on firmware — if you accidentally read them as uint32, a value just above or below zero flips into a huge number (two's complement). So read them as int32 and the sign and magnitude line up.&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;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;Grid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Exported"&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;huawei_grid_exported_energy&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37119&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;kWh"&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;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&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;Grid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Imported"&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;huawei_grid_imported_energy&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37121&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;kWh"&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;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grid export and import are the last two sources the Energy Dashboard needs for the full balance: export under "return to grid," import under "grid consumption." Once PV yield, battery and grid are entered, you have the complete Sankey diagram without a single custom line.&lt;/p&gt;

&lt;h2&gt;
  
  
  scale, precision and data_type — the three pitfalls
&lt;/h2&gt;

&lt;p&gt;Three fields decide whether the values are right. &lt;strong&gt;scale&lt;/strong&gt;: Huawei delivers energy in tenth- or hundredth-units — 0.01 turns the raw register into kWh. If your value is off by a factor of 10 or 100, it's almost always the scale. &lt;strong&gt;data_type&lt;/strong&gt;: energy counters are 32-bit (uint32/int32); an accidental uint16 clips at ~655 kWh and wraps back to zero. &lt;strong&gt;precision&lt;/strong&gt;: cosmetic only (decimal places), but two digits keep the history clean. If something stays "unavailable" after a restart, check slave (device ID, usually 1) and input_type (holding) first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling and verifying
&lt;/h2&gt;

&lt;p&gt;The sensor blocks go under the &lt;strong&gt;modbus:&lt;/strong&gt; hub in your configuration.yaml (or a file pulled in via !include). After "reload YAML configuration" → "Modbus" (or a restart) the entities appear. Go to Developer Tools → States and filter for sensor.pv_cumulative_yield and the battery/grid sensors: if you see plausible kWh values, you have a complete, upgrade-proof data source — with no custom integration at all. If you want to build derived metrics like self-sufficiency on top of these raw registers, that's covered in the &lt;a href="https://www.cloudapp.dev/en-US/home-assistant-pv-self-consumption-autarky-sensors" rel="noopener noreferrer"&gt;self-consumption sensors post&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why 127.0.0.1:5502 instead of the real SDongle IP?
&lt;/h3&gt;

&lt;p&gt;Because the SUN2000 SDongle allows only one concurrent Modbus connection. As soon as HA and a second client (evcc, AC·THOR, a script) connect directly, they choke each other. 127.0.0.1:5502 is a local caching proxy that polls the SDongle once and serves any number of clients. With a single client you can point host/port straight at the SDongle IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why uint32 / int32 and not just uint16?
&lt;/h3&gt;

&lt;p&gt;Energy counters in kWh quickly exceed the 16-bit range (max ~655 with scale 0.01) and therefore live across two consecutive registers. data_type uint32 (or int32 for signed grid values) reads both together. With uint16 you clip the high part and the counter appears to jump backwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will this break on a Home Assistant upgrade?
&lt;/h3&gt;

&lt;p&gt;No — that's exactly the point. The modbus platform is part of the HA core and maintained with it. There's no third-party integration that could become incompatible. In the worst case the spelling of a config key changes, which the release notes announce — but your data source never "goes orphaned."&lt;/p&gt;

&lt;h3&gt;
  
  
  Do these register addresses apply to every SUN2000?
&lt;/h3&gt;

&lt;p&gt;The addresses given here (32106 yield, 37780/37782 battery, 37119/37121 grid) apply to the common residential SUN2000 inverters with LUNA storage. For different firmware or other models, check the official Huawei Modbus interface document — layout and scale can differ slightly between generations, but the YAML structure stays the same.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>modbus</category>
      <category>solar</category>
      <category>iot</category>
    </item>
    <item>
      <title>Build Your Own Modbus-TCP Cache Proxy in Python: One Inverter, Many Home Assistant Clients</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 24 Jun 2026 14:59:06 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/build-your-own-modbus-tcp-cache-proxy-in-python-one-inverter-many-home-assistant-clients-1n4a</link>
      <guid>https://dev.to/cloudapp_dev/build-your-own-modbus-tcp-cache-proxy-in-python-one-inverter-many-home-assistant-clients-1n4a</guid>
      <description>&lt;p&gt;The Huawei SUN2000 SDongle has an annoying trait you only trip over once you want to connect more than one device: it accepts exactly &lt;strong&gt;one&lt;/strong&gt; concurrent Modbus-TCP connection. The moment Home Assistant polls it, the AC·THOR stops getting answers; let evcc squeeze in and one of the two gets dropped. In my setup three clients wanted to read the same registers at once — and the dongle let exactly one through.&lt;/p&gt;

&lt;p&gt;The usual advice is: use the ha-modbusproxy add-on. It works. But I wanted to understand what happens underneath, and I didn't want a black-box container for something that, at its core, is surprisingly small. So I wrote the proxy myself: roughly 300 lines of asyncio Python that poll the SDongle once every 10 seconds into an in-memory register cache and serve FC3 reads to any number of parallel clients. This post is the developer deep-dive to my &lt;a href="https://www.cloudapp.dev/en-US/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;concept post on caching Modbus proxies&lt;/a&gt; — that one is about the why, this one is about the how, down to the byte level.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: one upstream, many clients
&lt;/h2&gt;

&lt;p&gt;Modbus-TCP is a simple request-response protocol, but the SDongle is designed as a slave with exactly one master. Multiple masters at once aren't part of the standard, and Huawei enforces that hard. The solution is a proxy that behaves toward the dongle like the one permitted master, and toward every other device like a Modbus slave itself. The second half is the key: it answers client reads not by forwarding to the dongle, but from a cache. That way the dongle only ever sees one calm, periodic poller, no matter how many clients hang off the back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration: the only thing you change
&lt;/h2&gt;

&lt;p&gt;I parameterize the whole proxy through a handful of constants at the top of the file. In the normal case you only change your SDongle's IP and maybe the listen port. Everything else fits a standard Huawei installation.&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 ===
&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;192.0.2.10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- deine Huawei SDongle IP (RFC 5737 Beispiel)
&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="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="c1"&gt;# auf allen Interfaces lauschen
&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;# Sekunden
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy listens on port 5502 instead of 502 so it can run alongside the actual dongle and needs no root for a privileged port. In Home Assistant, AC·THOR and evcc you then simply enter the proxy host's IP and port 5502 — none of the clients notice they aren't talking to the dongle directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register batching: fewer roundtrips to the dongle
&lt;/h2&gt;

&lt;p&gt;The SDongle is slow, and every single read costs a full TCP roundtrip. Instead of querying each register individually, I read contiguous blocks in one go. Modbus allows up to 125 registers per FC3 read; I group the registers I need into a few batches along the natural gaps in the Huawei map.&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;REGISTER_BATCHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32016&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# PV string 1/2 voltage + current
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32064&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Input power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Active power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32106&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Cumulative energy yield (uint32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32114&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Daily energy yield (uint32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37760&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery SOC
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37765&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37780&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery total charge/discharge + daily
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry is a &lt;strong&gt;(start_address, count)&lt;/strong&gt; tuple. These eight batches cover everything my clients need — PV string values, power, yield and the full battery block. Per poll cycle that's eight small reads instead of dozens of individual queries, and the entire cycle finishes in well under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading one FC3 batch: packing and unpacking the MBAP frame
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. A Modbus-TCP frame is the 7-byte MBAP header (transaction ID, protocol ID, length, unit ID) plus the PDU (function code + payload). With &lt;strong&gt;struct.pack&lt;/strong&gt; I build the request frame, write it to the dongle, and unpack the response again. The format string &lt;strong&gt;"&amp;gt;HHHBBHH"&lt;/strong&gt; encodes exactly that structure: three big-endian uint16 for the MBAP head, then unit, function code and the two uint16 for start address and count.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&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="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHBBHH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&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;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;9&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_fc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHBBB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;9&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;resp_fc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Exception
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The &lt;strong&gt;asyncio.wait_for(..., timeout=3)&lt;/strong&gt; stops a hung dongle from blocking the whole poller — if the answer never comes, the read times out and the cycle drops into backoff. And the &lt;strong&gt;resp_fc &amp;gt;= 0x80&lt;/strong&gt; check catches Modbus exceptions: if the dongle sets the top bit of the function code, it's not a data response but an error, so I return None instead of unpacking garbage. The int32/uint32 values from the batches get reassembled later by the caller from two consecutive uint16 registers each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving the cache to every client
&lt;/h2&gt;

&lt;p&gt;The second half of the proxy is the server side. When a client connects and sends an FC3 read, I answer it not from the dongle but from the in-memory cache. I read the requested start address and count from the client PDU, pull the values out of the cache dictionary under an &lt;strong&gt;asyncio.Lock&lt;/strong&gt;, and build a valid Modbus response with a correct MBAP header back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ow"&gt;and&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;pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&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="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="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg_count&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="nf"&gt;append&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg_addr&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;byte_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;BB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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;resp_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&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;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;cache_lock&lt;/strong&gt; is not optional: the reader loop writes the cache while several client handlers read it at the same time — without the lock you could serve a half-updated register row. It's also important that I mirror the client's &lt;strong&gt;tx_id&lt;/strong&gt; (transaction ID) in the response; a correct Modbus master matches responses precisely on that field, and a wrong value makes some clients discard the answer. Missing registers I answer with 0 rather than an error — that keeps picky clients happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reader loop: poll, backoff, stale warning
&lt;/h2&gt;

&lt;p&gt;Holding it all together is a single long-running task that polls the dongle in a loop. As long as everything is fine it runs at the normal 10-second cadence. If a poll fails, it goes into a shorter retry, and if the cache gets too old, it writes a 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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reader_loop&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="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;The &lt;strong&gt;stale warning&lt;/strong&gt; after 120 seconds is my early-warning system: when it shows up in the log I know the dongle has stopped answering before the clients even start showing weird values. I deliberately kept the cache holding the last valid values on failure rather than dropping to zero — otherwise every dongle hiccup would push a PV power of 0 W to all clients and trigger false alarms and broken statistics in Home Assistant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Home Assistant and checking it
&lt;/h2&gt;

&lt;p&gt;On the HA side almost nothing changes versus a direct connection — you just point the Modbus hub at the proxy instead of the dongle. I now run three clients against the same upstream, and on the PV dashboard I see &lt;strong&gt;sensor.my_pv_pv_leistung&lt;/strong&gt;, &lt;strong&gt;sensor.my_pv_batteriestand&lt;/strong&gt; and &lt;strong&gt;sensor.pv_gesamt_ertrag&lt;/strong&gt; updating live — all sourced through the proxy at the same time, without the clients stealing the connection from each other. How I turn these raw values into self-consumption and autarky is in the &lt;a href="https://www.cloudapp.dev/en-US/home-assistant-pv-self-consumption-autarky-sensors" rel="noopener noreferrer"&gt;post on self-consumption and autarky sensors&lt;/a&gt;.&lt;/p&gt;

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

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

&lt;p&gt;You can, and for most people it's the right call. But I wanted to understand what happens under the hood and have full control over batching, cache behaviour and logging. If you're happy for an add-on to stay a black box, use the add-on; if you want to understand or extend the mechanism (e.g. your own computed registers, different poll intervals per batch), the home-built proxy is exactly right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Doesn't the cache serve stale values?
&lt;/h3&gt;

&lt;p&gt;At most as old as your poll interval — 10 seconds here. For PV power, battery SOC and yield that's perfectly fine; these values don't change meaningfully second by second. If you need it faster, lower POLL_INTERVAL, but keep in mind the SDongle gets cranky under overly aggressive polling. Ten seconds is the stable sweet spot for me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the proxy support writes (FC6/FC16)?
&lt;/h3&gt;

&lt;p&gt;Not this version — it's deliberately read-only and only handles FC3. That covers the sensor connection this post is about. Writes (say, a battery charge-mode control) you'd have to add, and then the 1-connection limit bites even harder: writes must not overlap. For pure data sharing across multiple clients, read-only is the safe and sufficient path.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if the SDongle drops out briefly?
&lt;/h3&gt;

&lt;p&gt;The reader loop goes into retry backoff and the cache holds the last valid values instead of falling to zero — so clients see no crash, just frozen values. After 120 seconds without an update the proxy writes a stale warning to the log. When the dongle comes back, the next successful poll refills the cache and the normal 10-second cadence resumes automatically.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>python</category>
      <category>iot</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Home Assistant — How to Install via Docker on an Azure Linux VM</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 23 Jun 2026 14:27:54 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm-1a5c</link>
      <guid>https://dev.to/cloudapp_dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm-1a5c</guid>
      <description>&lt;p&gt;Home Assistant is a powerful, open-source home automation platform that puts local control and privacy first. If you want to host it on the cloud, using a Linux VM in Azure and Docker is a robust and scalable option. Here’s a step-by-step guide to setting it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Setting Up the Azure Linux VM
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1.1: Create the Linux VM
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Log in to the &lt;a href="https://portal.azure.com/" rel="noopener noreferrer"&gt;Azure Portal&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Navigate to &lt;strong&gt;Virtual Machines&lt;/strong&gt; &amp;gt; &lt;strong&gt;Create&lt;/strong&gt; &amp;gt; &lt;strong&gt;Virtual Machine&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure the VM:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Subscription&lt;/strong&gt; : Select your Azure subscription.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource Group:&lt;/strong&gt; Create a new resource group or use an existing one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Virtual Machine Name&lt;/strong&gt; : Choose a descriptive name, e.g., HomeAssistantVM.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Region&lt;/strong&gt; : Select the region closest to you.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Image&lt;/strong&gt; : Choose &lt;strong&gt;Ubuntu Server 20.04 LTS&lt;/strong&gt; (or later).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Size&lt;/strong&gt; : Select an appropriate size, e.g., &lt;strong&gt;Standard B1ms&lt;/strong&gt; (sufficient for Home Assistant).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication Type&lt;/strong&gt; : Use SSH Public Key (recommended for security).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upload your SSH public key or generate one using ssh-keygen.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enable &lt;strong&gt;Public Inbound Ports&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Select &lt;strong&gt;Allow Selected Ports&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choose &lt;strong&gt;SSH (22)&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Click &lt;strong&gt;Next&lt;/strong&gt; through the Networking, Management, and other tabs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Review and create the VM.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 1.2: Connect to the VM
&lt;/h3&gt;

&lt;p&gt;Once the VM is created:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Find the public IP address of the VM in the Azure Portal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SSH into the VM using:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt; &lt;span class="k"&gt;ssh&lt;/span&gt; -i /path/to/private-key username@&amp;lt;VM_PUBLIC_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Prepare the Linux VM for Docker Installation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 2.1: Update the System
&lt;/h3&gt;

&lt;p&gt;Run the following commands to ensure the system is updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Install &amp;amp; Setup Docker
&lt;/h2&gt;

&lt;p&gt;Install dependencies for Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Install Home Assistant in Docker
&lt;/h2&gt;

&lt;p&gt;We change the directory to our home dir, and then we create a new directory for “Homeassistant”.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; /home/yourUser/homeassistant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we can start with the preconfigured container from “home-assistant”.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt; sudo docker run -d --name homeassistant --restart unless-stopped \
  -v /home/userdir/homeassistant:/config \
  --network=host \
  ghcr.io/home-assistant/home-assistant:stable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Here is a detailed explanation of what the command does:
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;sudo&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ensures the command runs with superuser privileges (necessary for Docker commands if not in the docker group).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;docker run&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This is the base command to create and start a new Docker container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;-d&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs the container in detached mode (in the background). The terminal will not be attached to the container’s output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;--name homeassistant&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assigns a name (homeassistant) to the container. This makes it easier to reference the container later using commands like docker start homeassistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;--restart unless-stopped&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configures the container to automatically restart if it stops unexpectedly (e.g., due to a system reboot). It will remain stopped only if you manually stop it with a docker stop command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;-v /home/userdir/homeassistant:/config&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Mounts a volume between the host and the container:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;/home/userdir/homeassistant -&amp;gt; is a directory on the host machine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;/config -&amp;gt; is the corresponding directory inside the container where Home Assistant stores its configuration files.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This ensures that your configurations persist across container restarts or updates.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;--network=host&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Configures the container to use the host’s networking stack directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This eliminates network isolation between the container and the host, allowing Home Assistant to directly access the host’s network interfaces (important for Home Assistant to detect devices on the same network).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ghcr.io/home-assistant/home-assistant:stable&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Specifies the Docker image to use:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ghcr.io/home-assistant/home-assistant -&amp;gt; is the image's location in GitHub Container Registry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;:stable -&amp;gt; is the tag specifying the stable version of the Home Assistant image. If omitted, Docker will default to the latest tag.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Access Home Assistant
&lt;/h2&gt;

&lt;p&gt;Open a browser and navigate to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; http://&amp;lt;VM_PUBLIC_IP&amp;gt;:8123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Complete the initial setup by following the on-screen instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firewall Settings Azure VM
&lt;/h2&gt;

&lt;p&gt;Don’t forget to open the TCP port 8123 on your Azure VM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpspij486abqyze94hhl2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpspij486abqyze94hhl2.png" alt="network settings azure vm" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now you have Home Assistant up and running. In the next posts, I will show you how to setup “HACS” Home Assistant Community Store and further configuration steps. Home Automation is not so tricky ;-)&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudapp-dev, and before you leave us
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Thank you for reading until the end. Before you go:&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;em&gt;Please consider&lt;/em&gt; &lt;strong&gt;&lt;em&gt;clapping&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;and&lt;/em&gt; &lt;strong&gt;&lt;em&gt;following&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;the writer! 👏 on our&lt;/em&gt; &lt;a href="https://medium.com/@cloudapp_dev" rel="noopener noreferrer"&gt;&lt;em&gt;Medium Account&lt;/em&gt;&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://x.com/Cloudapp_dev" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Or follow us on twitter -&amp;gt; Cloudapp.dev&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>tutorial</category>
      <category>homeassistant</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
