<?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.clauneck.workers.dev/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.clauneck.workers.dev/cloudapp_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.clauneck.workers.dev/feed/cloudapp_dev"/>
    <language>en</language>
    <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.clauneck.workers.dev/cloudapp_dev/build-your-own-modbus-tcp-cache-proxy-in-python-one-inverter-many-home-assistant-clients-1n4a</link>
      <guid>https://dev.clauneck.workers.dev/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.clauneck.workers.dev/cloudapp_dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm-1a5c</link>
      <guid>https://dev.clauneck.workers.dev/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>
    <item>
      <title>Control an AC·THOR 9s from Home Assistant When Modbus Writes Are Blocked: the my-PV Cloud API</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Fri, 19 Jun 2026 06:27:34 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/control-an-acthor-9s-from-home-assistant-when-modbus-writes-are-blocked-the-my-pv-cloud-api-1kmh</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/control-an-acthor-9s-from-home-assistant-when-modbus-writes-are-blocked-the-my-pv-cloud-api-1kmh</guid>
      <description>&lt;p&gt;I wanted something that sounds trivial: to boost my AC·THOR 9s — the my-PV heating element that turns PV surplus into hot water — to a target temperature from Home Assistant whenever the evening didn't bring enough sun. Reading over Modbus TCP had worked for ages: boiler temperature, power, all sitting cleanly as sensors in HA. So I figured the write path was one line of YAML away.&lt;/p&gt;

&lt;p&gt;It wasn't. Every Modbus write to the AC·THOR ended in a &lt;strong&gt;connection refused&lt;/strong&gt;. No timeout, no silent failure — the device actively rejects write attempts. It's in no documentation, and it cost me an evening of debugging to understand: on the AC·THOR 9s, Modbus TCP is read-only. Full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: Modbus reads yes, writes no
&lt;/h2&gt;

&lt;p&gt;The split is hard and reproducible. Monitoring works permanently over Modbus, control doesn't at all. If you, like me, build the read sensors first and feel pleased with yourself, you hit a wall the moment the first set command lands. Here's the inventory I noted down after the debugging session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BLOCKED:  Modbus-TCP write operations (device-side restriction, 'connection refused' on every write attempt)
WORKING:  Control via Cloud API PUT /setup
WORKING:  Temperature monitoring via Modbus-TCP still works (read-only)

Tested parameters (PUT /setup, JSON body):
  bstmode    Assurance/boost mode           0=Off, 1=On
  ww1boost   Target temperature             550 = 55.0°C   (tenths °C)
  ww1target  Max temperature, solar mode    650 = 65.0°C   (tenths °C)
  bstton1    Boost window 1 start           Hour 0-23
  bsttof1    Boost window 1 end             Hour 0-23

Gotchas:
  - Propagation delay: a change only takes effect after 4-6 s on the device.
  - The API replies immediately with 'ok' (no proof the value is set yet).
  - Wait before the GET /setup verification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rescue is the my-PV Cloud API. It's barely documented, but it accepts exactly the write commands that local Modbus refuses. So the path doesn't go over the LAN straight to the device — it takes the detour through the my-PV cloud, which the device then syncs from itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: the my-PV Cloud API via PUT /setup
&lt;/h2&gt;

&lt;p&gt;The API has a single control endpoint that matters: &lt;strong&gt;PUT /api/v1/device//setup&lt;/strong&gt;. The JSON body carries exactly the fields you want to change — everything else stays untouched. You authenticate with an Authorization header carrying your cloud API token. That's the whole transport mechanism: a PUT with a few keys in the body.&lt;/p&gt;

&lt;p&gt;An important framing: this post only covers the &lt;strong&gt;transport layer&lt;/strong&gt; — that is, how the write command reaches the device at all. The actual surplus logic (when to boost, boost-stop at 55 °C) belongs in a separate automation and is the subject of the &lt;a href="https://www.cloudapp.dev/en-US/home-assistant-ac-thor-pv-surplus-hot-water" rel="noopener noreferrer"&gt;AC·THOR surplus post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell_command.yaml — set boost and target temperature
&lt;/h2&gt;

&lt;p&gt;Since there's no native HA integration for the my-PV cloud, I build the write commands as &lt;strong&gt;shell_command&lt;/strong&gt; entries using curl. Each entry is a single PUT. Replace &lt;strong&gt;DEVICE_SERIAL&lt;/strong&gt; with your my-PV serial and &lt;strong&gt;YOUR_MYPV_API_TOKEN&lt;/strong&gt; with your cloud API token (ideally pulled from !secret — inline here for readability):&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;# AC·THOR 9s Cloud API commands (direct REST calls)&lt;/span&gt;
&lt;span class="c1"&gt;# Uses the my-PV Cloud API, since Modbus-TCP write is blocked device-side.&lt;/span&gt;
&lt;span class="c1"&gt;# Replace placeholders:&lt;/span&gt;
&lt;span class="c1"&gt;#   DEVICE_SERIAL = your my-PV device serial number&lt;/span&gt;
&lt;span class="c1"&gt;#   YOUR_MYPV_API_TOKEN = your my-PV Cloud API authorization token&lt;/span&gt;

&lt;span class="na"&gt;acthor_enable_boost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"https://api.my-pv.com/api/v1/device/DEVICE_SERIAL/setup"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Authorization:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;YOUR_MYPV_API_TOKEN"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Content-Type:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;application/json"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{\"bstmode\":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1}"'&lt;/span&gt;
&lt;span class="na"&gt;acthor_disable_boost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"https://api.my-pv.com/api/v1/device/DEVICE_SERIAL/setup"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Authorization:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;YOUR_MYPV_API_TOKEN"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Content-Type:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;application/json"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{\"bstmode\":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0}"'&lt;/span&gt;

&lt;span class="c1"&gt;# Target temperature (in tenths °C: 550=55°C, 600=60°C, 650=65°C)&lt;/span&gt;
&lt;span class="na"&gt;acthor_set_target_temp_55&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"https://api.my-pv.com/api/v1/device/DEVICE_SERIAL/setup"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Authorization:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;YOUR_MYPV_API_TOKEN"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Content-Type:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;application/json"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{\"ww1boost\":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;550}"'&lt;/span&gt;

&lt;span class="c1"&gt;# Max solar temperature&lt;/span&gt;
&lt;span class="na"&gt;acthor_set_max_temp_65&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"https://api.my-pv.com/api/v1/device/DEVICE_SERIAL/setup"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Authorization:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;YOUR_MYPV_API_TOKEN"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Content-Type:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;application/json"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{\"ww1target\":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;650}"'&lt;/span&gt;

&lt;span class="c1"&gt;# Boost window (start/end as hour 0-23)&lt;/span&gt;
&lt;span class="na"&gt;acthor_enable_boost_with_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"https://api.my-pv.com/api/v1/device/DEVICE_SERIAL/setup"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Authorization:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;YOUR_MYPV_API_TOKEN"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Content-Type:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;application/json"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{\"bstmode\":&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;\"bstton1\":&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;start_hour&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;\"bsttof1\":&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;end_hour&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}}"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a reload of the shell commands, &lt;strong&gt;shell_command.acthor_enable_boost&lt;/strong&gt; and the others are available as services in HA and can be called from any automation or from the Developer Tools service tab. That restores the write path that Modbus refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameters that actually work
&lt;/h2&gt;

&lt;p&gt;I tested the fields one by one, because the my-PV docs are silent here. These four (plus the two time-window fields) are enough for full boost control. &lt;strong&gt;bstmode&lt;/strong&gt; is the switch: 1 starts the boost, 0 stops it. &lt;strong&gt;ww1boost&lt;/strong&gt; is the boost's target temperature, &lt;strong&gt;ww1target&lt;/strong&gt; the maximum temperature in normal solar operation — both in tenths of a degree, so 550 means 55.0 °C. &lt;strong&gt;bstton1&lt;/strong&gt; and &lt;strong&gt;bsttof1&lt;/strong&gt; define a boost window as a whole hour (0–23).&lt;/p&gt;

&lt;p&gt;The most common beginner mistake is the unit: send 55 instead of 550 and you set the device to a 5.5 °C target — effectively off. The tenths-of-a-degree convention isn't documented cleanly anywhere, but it's consistent across all temperature fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: the propagation delay
&lt;/h2&gt;

&lt;p&gt;This is where I lost the most time. The API answers every PUT &lt;strong&gt;instantly with 'ok'&lt;/strong&gt; — but that only means the cloud accepted the command, not that the device has applied the value yet. It takes &lt;strong&gt;4 to 6 seconds&lt;/strong&gt; before the change actually reaches the AC·THOR.&lt;/p&gt;

&lt;p&gt;In practice that means: if you verify with GET /setup immediately after writing, you'll still read the old value and conclude the command failed. So build in a short wait after the PUT before triggering the verification or a follow-up action. In an HA automation I do this with a &lt;strong&gt;delay&lt;/strong&gt; of a few seconds between the shell_command and the next step — that eliminated every phantom failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring stays on Modbus
&lt;/h2&gt;

&lt;p&gt;A pleasant side effect: you don't have to give up the working Modbus read. On my setup HA keeps reading the boiler temperature and power locally over Modbus TCP — no cloud dependency, no rate limit. Only the write path goes through the cloud. This hybrid (read locally, write via cloud) is robust: if the internet drops you lose control, not the display. If you wire up the inverter behind it over Modbus too, the basics are in the &lt;a href="https://www.cloudapp.dev/en-US/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;Modbus cache post&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why doesn't the AC·THOR 9s allow Modbus writes?
&lt;/h3&gt;

&lt;p&gt;It's a device-side restriction from my-PV: the Modbus TCP server on the AC·THOR is designed read-only, and every write attempt is rejected with 'connection refused'. Control is intended exclusively via the official app, the local web interface, or the cloud API. It's not a bug in your configuration — it's the manufacturer's intent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I strictly need the cloud, or can I do it locally?
&lt;/h3&gt;

&lt;p&gt;For API write access, the documented path goes through the my-PV cloud (api.my-pv.com). Some firmware versions additionally expose local HTTP endpoints, but that's version-dependent and not guaranteed. The cloud path shown here works reliably across devices — the cost is an internet dependency for control. Monitoring stays local and unaffected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my verification fail right after writing?
&lt;/h3&gt;

&lt;p&gt;Almost certainly the propagation delay. The API acknowledges instantly with 'ok', but the device only applies the value after 4–6 seconds. If you query GET /setup immediately afterward, you still read the old state. Wait a few seconds between writing and reading and the result is correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I hide the serial number and token in the config?
&lt;/h3&gt;

&lt;p&gt;Put both in secrets.yaml and reference them with !secret in the shell_command. That keeps neither the device serial nor the cloud API token in versioned YAML. Be especially careful that the token never appears in logs, screenshots, or a Git repo — it grants full write access to your device.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>solar</category>
      <category>api</category>
      <category>iot</category>
    </item>
    <item>
      <title>Detect a Failing PV String in Home Assistant: Shading, Dead Module or Loose MC4</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Wed, 17 Jun 2026 07:23:11 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/detect-a-failing-pv-string-in-home-assistant-shading-dead-module-or-loose-mc4-3f5b</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/detect-a-failing-pv-string-in-home-assistant-shading-dead-module-or-loose-mc4-3f5b</guid>
      <description>&lt;p&gt;A PV string can quietly underperform for months, and you only notice on the annual statement. Partial shading from a tree that grew taller, a module with a failed bypass diode, an MC4 connector that worked itself loose over the years — each one costs yield without throwing a single error. My inverter happily shows green while half a string sits in shade.&lt;/p&gt;

&lt;p&gt;The fix is surprisingly simple and needs no extra hardware: if your system has two MPPT trackers, the two strings should track each other closely throughout the day when they face the same direction. If they drift apart for any length of time, something is wrong. Here is how to watch for exactly that in Home Assistant, in three steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: compare your two MPPT strings
&lt;/h2&gt;

&lt;p&gt;On a system with two equally-oriented strings, the current per string is nearly identical across the day. Voltage tells you little (it stays fairly stable even under shade), but &lt;strong&gt;current&lt;/strong&gt; drops the moment a module in one string gets less light or is electrically worse connected. So we continuously compute the percentage difference between the two string currents and raise an alert when it stays above a threshold — not on a passing cloud, but on a sustained deviation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — read each string's current over Modbus
&lt;/h2&gt;

&lt;p&gt;The data comes from the inverter's Modbus holding registers. On my Huawei SUN2000, voltage and current per string sit right next to each other: 32016/32017 for string 1, 32018/32019 for string 2 (voltage scale 0.1, current scale 0.01). How to wire the inverter up over Modbus in the first place is covered in the &lt;a href="https://www.cloudapp.dev/en-US/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;Modbus basics post&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# inside a Modbus TCP hub (host/port = your own inverter/SDongle/proxy)&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;String&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;Voltage"&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;32016&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;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&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;V"&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;voltage&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;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;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Current"&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;32017&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;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;A"&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;current&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;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;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Voltage"&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;32018&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;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&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;V"&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;voltage&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;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;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Current"&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;32019&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;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;A"&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;current&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;Register addresses are vendor-specific. On a different inverter (Fronius, SMA, GoodWe) you'll find the string registers in the vendor's Modbus map — the logic after that is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — a template sensor for the percentage difference
&lt;/h2&gt;

&lt;p&gt;This template sensor computes the deviation relative to the stronger of the two strings. The key detail is the 0.5 A floor: at dawn and dusk, when both strings deliver near zero, any tiny mismatch would blow up to a huge percentage — the floor masks that twilight window and cleanly returns 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="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;Difference"&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;pv_string_difference&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:solar-panel"&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_current') | float(0) %}&lt;/span&gt;
    &lt;span class="s"&gt;{% set s2 = states('sensor.pv_string_2_current') | 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;h2&gt;
  
  
  Step 3 — the alert automation with a false-alarm guard
&lt;/h2&gt;

&lt;p&gt;Now it all comes together. The automation fires when the difference exceeds 30 % and holds for &lt;strong&gt;30 minutes&lt;/strong&gt;. The second guard is the condition "string 1 current above 1 A": it prevents false alarms at night and around sunrise/sunset, when the array barely produces anything anyway.&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;pv_string_anomaly_warning&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;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;Anomaly&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Warning"&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Warn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&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;strings&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;differ&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;30%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;min"&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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.pv_string_difference&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;30&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;30&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;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.pv_string_1_current&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;1&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;notify.mobile_app_your_phone&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;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;Anomaly"&lt;/span&gt;
      &lt;span class="na"&gt;message&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;String 1: {{ states('sensor.pv_string_1_voltage') }}V / {{ states('sensor.pv_string_1_current') }}A |&lt;/span&gt;
        &lt;span class="s"&gt;String 2: {{ states('sensor.pv_string_2_voltage') }}V / {{ states('sensor.pv_string_2_current') }}A |&lt;/span&gt;
        &lt;span class="s"&gt;Difference: {{ states('sensor.pv_string_difference') }}% |&lt;/span&gt;
        &lt;span class="s"&gt;Check for shading, a dead module or a loose connector.&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 push message names voltage and current of both strings plus the difference — you see right on the lock screen which string is weak, without opening the app. Replace &lt;strong&gt;notify.mobile_app_your_phone&lt;/strong&gt; with your own mobile device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the warning actually catches
&lt;/h2&gt;

&lt;p&gt;In practice it's three causes. &lt;strong&gt;Shading&lt;/strong&gt;: a tree, a new rooftop structure, or in winter the chimney's afternoon shadow — the affected string drops reproducibly at the same time of day. &lt;strong&gt;A dead module&lt;/strong&gt;: a blown bypass diode or microcracks pull one string down permanently. &lt;strong&gt;A loose MC4 connector&lt;/strong&gt;: a connector that has corroded over the years or never quite clicked in raises resistance — dangerous, because in the worst case it gets hot. A 30 % difference that doesn't depend on the time of day is exactly the signal worth chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning the thresholds
&lt;/h2&gt;

&lt;p&gt;30 % and 30 minutes are a good starting point for two nominally identical strings. For strings of different size or orientation (east/west) a constant baseline difference is normal — then raise the threshold, or compare specific yield per module instead. Want a more sensitive warning? Drop to 20 %. Want quiet? Stretch the &lt;strong&gt;for&lt;/strong&gt; duration to 60 minutes.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why 30 % and not a smaller threshold?
&lt;/h3&gt;

&lt;p&gt;Below about 20 % you're in the range of normal scatter from drifting clouds, slightly different module temperatures and measurement noise. 30 % over 30 minutes reliably separates real faults from weather. For very uniform systems you can carefully tighten it after a few weeks of observation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with a single-string inverter?
&lt;/h3&gt;

&lt;p&gt;The direct string comparison doesn't — it needs two MPPT trackers. On a single-string system you'd compare actual against expected power (from irradiance/time of day) instead, which is considerably more work. That's exactly why the two-string comparison is so attractive: it needs no reference, the strings are each other's reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will it false-alarm on cloudy days?
&lt;/h3&gt;

&lt;p&gt;No — clouds hit both strings at once, so the difference stays small. That's precisely why we compare the strings against each other rather than against a fixed value. The 30-minute condition additionally absorbs short, uneven shadow passes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Modbus registers does my inverter use?
&lt;/h3&gt;

&lt;p&gt;The 32016–32019 above are for Huawei SUN2000. Other vendors have their own maps; search your inverter's Modbus document for "PV1 Voltage/Current" and "PV2 Voltage/Current". The scale factors (0.1 for voltage, 0.01 for current) are vendor-dependent too — if values are off by a factor of 10, it's almost always the scale.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>solar</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
    <item>
      <title>The Best HACS Integrations for Home Assistant (My Must-Haves)</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 16 Jun 2026 06:10:58 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/the-best-hacs-integrations-for-home-assistant-my-must-haves-4n0j</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/the-best-hacs-integrations-for-home-assistant-my-must-haves-4n0j</guid>
      <description>&lt;p&gt;Once HACS is installed, the real question is what to put in it. (New to HACS? Start with &lt;a href="https://www.cloudapp.dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user" rel="noopener noreferrer"&gt;What is HACS?&lt;/a&gt; and &lt;a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant" rel="noopener noreferrer"&gt;how to install HACS&lt;/a&gt;.) Here are the HACS integrations I actually run in my own Home Assistant setup – followed by the community must-haves worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HACS integrations I actually use
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Huawei Solar
&lt;/h3&gt;

&lt;p&gt;This is how I read my Huawei SUN2000 inverter over Modbus: power, daily yield, battery state and grid import/export all land as native Home Assistant sensors – no vendor cloud in the loop. If you run a Huawei inverter, it's the single most useful integration on this list. I wrote up the Modbus side in detail in &lt;a href="https://www.cloudapp.dev/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;caching a Huawei SUN2000 over Modbus&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tapo: Cameras Control
&lt;/h3&gt;

&lt;p&gt;Brings TP-Link Tapo cameras into Home Assistant as proper entities – stream, motion detection, privacy mode and more – without depending on the Tapo cloud. If you've got Tapo gear, this turns it into first-class HA devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  ShellyForHass
&lt;/h3&gt;

&lt;p&gt;The community Shelly integration. Worth being honest here: Home Assistant now ships a &lt;strong&gt;core&lt;/strong&gt; Shelly integration too, so for a fresh setup you can often use that instead. I still run ShellyForHass, but check which one fits before adding it.&lt;/p&gt;

&lt;h3&gt;
  
  
  button-card
&lt;/h3&gt;

&lt;p&gt;My go-to custom Lovelace card. &lt;strong&gt;button-card&lt;/strong&gt; gives you fully templatable buttons and tiles – custom states, icons, colours and tap actions – which is what most "how did they build that dashboard?" screenshots are really made of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other popular HACS must-haves worth knowing
&lt;/h2&gt;

&lt;p&gt;These are community favourites I'd recommend looking at, even where they aren't in my own stack:&lt;/p&gt;

&lt;h3&gt;
  
  
  Mushroom
&lt;/h3&gt;

&lt;p&gt;A set of clean, minimal dashboard cards – the fastest way to a modern-looking Lovelace UI without hand-writing CSS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Card Mod
&lt;/h3&gt;

&lt;p&gt;Apply custom CSS to almost any card. The standard tool once you want your dashboard to look exactly your way.&lt;/p&gt;

&lt;h3&gt;
  
  
  mini-graph-card
&lt;/h3&gt;

&lt;p&gt;Compact, good-looking history graphs for any sensor – ideal for energy, temperature and humidity at a glance.&lt;/p&gt;

&lt;h3&gt;
  
  
  auto-entities
&lt;/h3&gt;

&lt;p&gt;Populate cards automatically by filter (area, domain, attribute) instead of maintaining entity lists by hand. A huge time-saver as your setup grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frigate
&lt;/h3&gt;

&lt;p&gt;Local AI object detection for cameras/NVR. If you outgrow basic camera control and want person/car detection that runs on your own hardware, this is the one.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Are HACS integrations free?
&lt;/h3&gt;

&lt;p&gt;Almost all are free and open source. The integration itself stays free even when the underlying device or service has a paid tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many HACS integrations should I install?
&lt;/h3&gt;

&lt;p&gt;Only the ones you'll actually use – each adds maintenance and update overhead. A handful of well-chosen integrations beats dozens of half-used ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do HACS integrations update automatically?
&lt;/h3&gt;

&lt;p&gt;No. HACS notifies you when an update is available; you apply it with a click and a restart. Updates aren't silent by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;Not sure what HACS even is yet? Read &lt;a href="https://www.cloudapp.dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user" rel="noopener noreferrer"&gt;What is HACS?&lt;/a&gt; – or if you haven't set it up, follow &lt;a href="https://www.cloudapp.dev/how-to-install-hacs-in-home-assistant" rel="noopener noreferrer"&gt;how to install HACS in Home Assistant&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>hacs</category>
      <category>smarthome</category>
      <category>automation</category>
    </item>
    <item>
      <title>An Open-Source SEO + GEO Audit Toolkit in Plain Node</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Sun, 14 Jun 2026 08:31:42 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/an-open-source-seo-geo-audit-toolkit-in-plain-node-4foc</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/an-open-source-seo-geo-audit-toolkit-in-plain-node-4foc</guid>
      <description>&lt;p&gt;I run this blog on a self-hosted stack, and I like knowing exactly how healthy it is — broken links, metadata, rankings, the lot. The tools that answer those questions properly start at around $99 a month, and I mostly needed the answers once a week. So over the last few weeks I built my own: four small Node scripts, each answering one question, each producing a markdown report. Today I cleaned them up and put them on GitHub.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/lireking/seo-geo-audit" rel="noopener noreferrer"&gt;seo-geo-audit&lt;/a&gt; — MIT-licensed, about 1,500 lines total, and &lt;strong&gt;zero npm dependencies&lt;/strong&gt; (one exception, more on that below). Every tool is a single command, and every report is plain markdown you can read in the terminal, diff in git, or paste into an issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why GEO? AI crawlers don't run your JavaScript
&lt;/h2&gt;

&lt;p&gt;GEO — Generative Engine Optimization — asks whether AI answer engines like ChatGPT, Claude or Perplexity can actually read and cite your site. This stopped being theoretical for me when AI assistants started showing up as referrers in my own analytics. Those visitors are real, and they arrive because an AI read your page and linked it.&lt;/p&gt;

&lt;p&gt;Here is the catch: most AI crawlers fetch your raw server HTML and &lt;strong&gt;do not execute JavaScript&lt;/strong&gt;. Google renders your page; GPTBot, ClaudeBot and PerplexityBot mostly don't. So structured data your framework injects client-side is invisible to them, even though every SEO browser extension tells you it's fine. The same goes for metadata that streaming frameworks flush into the body instead of the initial head — Google relocates it, a JS-less crawler misses it. My own site had six pages doing exactly that, and I only know because the crawler flags it as its own issue category.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tools
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;seo-audit&lt;/strong&gt; crawls your sitemap, then every internal link and image target, and analyzes the raw server HTML — deliberately the JS-less view. It covers the classic checks (titles, descriptions, canonicals, hreflang, Open Graph, broken links with redirect-chain resolution, sitemap hygiene) plus the GEO set: client-side-only JSON-LD, metadata streamed to the body, heading-outline gaps, thin content, llms.txt presence, and AI crawlers blocked in robots.txt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;seo-audit/run.sh https://your-site.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;perf-audit&lt;/strong&gt; is the one tool with a dependency — it drives a real browser via Playwright. It measures lab Core Web Vitals (LCP, CLS, FCP, TTFB, TBT) against Google's thresholds, pulls real-user CrUX field data including INP through the free PageSpeed Insights API, tracks a performance budget per page, and — most usefully — captures the post-hydration DOM so you can diff what Google sees against what AI crawlers see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gsc-fetch&lt;/strong&gt; talks to the Search Console API and computes the two lists a solo operator actually acts on: striking-distance queries (position 5–20 with real impressions — one title tweak from page 1) and low-CTR winners (already top 5, earning fewer clicks than the position implies, with an estimate of the clicks left on the table). That second list is literally my editorial backlog now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;umami-fetch&lt;/strong&gt; pulls a self-hosted Umami v3 instance: traffic channels, top pages, custom events, UTM campaigns — and a datacenter-adjusted totals row, because one datacenter country turned out to be a third of my “visits” before I started subtracting it. Umami's API only filters by equality, so the tool fetches the suspect countries separately and does the math.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found on my own site
&lt;/h2&gt;

&lt;p&gt;Eating my own dog food was sobering: six pages with metadata invisible to JS-less crawlers, eighty meta descriptions over the length limit, a broken internal link target I had missed for weeks, bot traffic inflating my visitor numbers by half, and a brand query ranking #1 with a 0% click-through rate. None of this was visible in any single dashboard I had. An audit you can re-run in thirty seconds is much harder to ignore than a subscription you check monthly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Philosophy
&lt;/h2&gt;

&lt;p&gt;One command, one markdown report. No build step, no config file with eighty options, no platform. Plain Node scripts you can read in one sitting and edit to your needs — sharp little knives, not a Swiss army knife. If a check doesn't apply to your stack, delete it; it's your copy.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is it free?
&lt;/h3&gt;

&lt;p&gt;Yes — MIT license, no paid tier. The only optional costs are third-party APIs: PageSpeed Insights is free with a Google API key, Search Console is free, and backlink data is the one thing that genuinely has no free source (the tool plugs into a paid provider if you have one, and says so honestly if you don't).&lt;/p&gt;

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

&lt;p&gt;Generative Engine Optimization: making your content readable, parseable and citable for AI answer engines. In practice it overlaps heavily with technical SEO — the difference is the renderer. AI crawlers read raw HTML, so anything that only exists after JavaScript runs does not exist for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need API keys?
&lt;/h3&gt;

&lt;p&gt;The crawler needs nothing — clone and run against any site. PageSpeed field data needs a free Google API key, Search Console needs a one-time OAuth flow (the kit includes a dependency-free helper that mints the refresh token), and the analytics tool needs your own Umami login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/lireking/seo-geo-audit
&lt;span class="nb"&gt;cd &lt;/span&gt;seo-geo-audit
seo-audit/run.sh https://your-site.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all there is to it — the repo is at &lt;a href="https://github.com/lireking/seo-geo-audit" rel="noopener noreferrer"&gt;github.com/lireking/seo-geo-audit&lt;/a&gt;. If it flags something on your site that it shouldn't (or misses something it should catch), open an issue. PRs welcome — the scope stays small on purpose.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>seo</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Install HACS in Home Assistant – Step by Step</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Thu, 11 Jun 2026 13:17:41 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/how-to-install-hacs-in-home-assistant-step-by-step-26lc</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/how-to-install-hacs-in-home-assistant-step-by-step-26lc</guid>
      <description>&lt;p&gt;Want to use custom integrations, themes, or dashboard cards that aren't in Home Assistant's default catalog? Then you need &lt;strong&gt;HACS – the Home Assistant Community Store&lt;/strong&gt;. (For what HACS actually is, see &lt;a href="https://www.cloudapp.dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user" rel="noopener noreferrer"&gt;What is HACS?&lt;/a&gt;.) HACS is the first thing I set up on every fresh Home Assistant instance – most of my own setup, from KNX automations to PV monitoring, relies on integrations that only exist there. This guide walks you through installing HACS step by step – from download to GitHub authorization to your first integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;A running Home Assistant installation. &lt;strong&gt;You need to know which type you run&lt;/strong&gt; (HAOS/Supervised vs. Container/Core) – the install method differs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container / Core:&lt;/strong&gt; terminal access to the config directory. &lt;strong&gt;HAOS / Supervised:&lt;/strong&gt; no terminal needed.&lt;/p&gt;

&lt;p&gt;A free &lt;a href="https://github.com" rel="noopener noreferrer"&gt;GitHub account&lt;/a&gt; – HACS loads its integrations through GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install HACS step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Download HACS
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;HAOS / Supervised (the most common setup):&lt;/strong&gt; go to Settings → Add-ons → Add-on Store → ⋮ (top right) → Repositories, and add &lt;strong&gt;&lt;a href="https://github.com/hacs/addons" rel="noopener noreferrer"&gt;https://github.com/hacs/addons&lt;/a&gt;&lt;/strong&gt;. Then install the &lt;strong&gt;Get HACS&lt;/strong&gt; add-on, start it, and follow the instructions in the add-on logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container / Core:&lt;/strong&gt; run the official installer inside your Home Assistant config directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget &lt;span class="nt"&gt;-O&lt;/span&gt; - https://get.hacs.xyz | bash -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Restart Home Assistant
&lt;/h3&gt;

&lt;p&gt;Go to Settings → System → Restart. Home Assistant only detects the new integration after a restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add the HACS integration
&lt;/h3&gt;

&lt;p&gt;Go to Settings → Devices &amp;amp; Services → Add Integration and search for HACS. &lt;strong&gt;Important:&lt;/strong&gt; HACS only shows up after you clear your browser cache or do a hard refresh – this is the most common "HACS doesn't appear" cause. Then acknowledge the statements and submit.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Authorize with GitHub and finish
&lt;/h3&gt;

&lt;p&gt;HACS uses a GitHub device OAuth flow: copy the device code it shows, open &lt;a href="https://github.com/login/device" rel="noopener noreferrer"&gt;github.com/login/device&lt;/a&gt;, sign in, enter the code, and authorize HACS. Back in Home Assistant, assign HACS to an area and select Finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Install your first integration
&lt;/h3&gt;

&lt;p&gt;HACS now appears in your sidebar. Open it, browse the available integrations, install one, and restart Home Assistant. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HACS doesn't appear in the integration list:&lt;/strong&gt; clear your browser cache or hard-refresh (officially the most common cause), and double-check you restarted Home Assistant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HACS stays empty:&lt;/strong&gt; the GitHub authorization (step 4) was skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub rate limit:&lt;/strong&gt; wait a few minutes and run the authorization again.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is HACS free?
&lt;/h3&gt;

&lt;p&gt;Yes – HACS is open source and completely free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a GitHub account?
&lt;/h3&gt;

&lt;p&gt;Yes – HACS loads its integrations through GitHub, so a free account is required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does HACS work with Home Assistant Container or Core?
&lt;/h3&gt;

&lt;p&gt;Yes – the one-line installer works on all installation types. On HAOS, the Get HACS add-on is the easiest route.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I update HACS integrations?
&lt;/h3&gt;

&lt;p&gt;HACS shows available updates directly in its panel; a single click plus a restart is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;HACS is up and running. Next, learn &lt;strong&gt;what HACS is and why it's a must-have&lt;/strong&gt; for every Home Assistant user in &lt;a href="https://www.cloudapp.dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user" rel="noopener noreferrer"&gt;What is HACS?&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>hacs</category>
      <category>smarthome</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why the Home Assistant Community Store (HACS) is a Must-Have for Every HA User</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Tue, 09 Jun 2026 07:30:43 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user-omg</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user-omg</guid>
      <description>&lt;p&gt;For my first few months with Home Assistant I stuck to the built-in integrations and wondered why everyone online had dashboards and devices I just couldn't find. The answer was HACS — the Home Assistant Community Store. It's the one add-on that unlocks the huge ecosystem of community integrations, cards and themes that aren't in core, and it's the first thing I install on any fresh HA setup now. Here's what HACS is, why it matters, and how it changed the way I run my smart home.&lt;/p&gt;

&lt;p&gt;Home automation is all about customization and flexibility. For those using &lt;a href="https://www.home-assistant.io/" rel="noopener noreferrer"&gt;Home Assistant&lt;/a&gt;, the open-source platform for managing your smart home, you likely already appreciate its powerful integrations and ability to grow alongside your needs. But even with its extensive core capabilities, there’s a way to unlock even more potential: the &lt;strong&gt;Home Assistant Community Store (HACS)&lt;/strong&gt;.In this story, we’ll explore HACS, why it’s a game-changer, and how to get started with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is HACS?
&lt;/h2&gt;

&lt;p&gt;HACS — the &lt;strong&gt;Home Assistant Community Store&lt;/strong&gt; — is a free, open-source add-on that lets you discover, install, and update community-built integrations, themes, dashboard cards, and automations inside Home Assistant, all from one place. It isn’t part of Home Assistant by default and isn’t an official add-on store; think of it as a community-run “app store” that fills the gap between Home Assistant’s built-in integrations and the thousands of custom ones the community maintains on GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Use HACS?
&lt;/h2&gt;

&lt;p&gt;Here are a few compelling reasons to add HACS to your Home Assistant setup:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Expand Home Assistant’s Capabilities
&lt;/h2&gt;

&lt;p&gt;Home Assistant already supports many devices and services, but HACS takes it further by introducing custom integrations. For instance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Want to integrate niche smart devices or APIs not officially supported? HACS has got you covered.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Do you need a new feature, like advanced analytics or a custom card for your dashboard? HACS is where you’ll find it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Stunning Custom Dashboards
&lt;/h2&gt;

&lt;p&gt;Home Assistant’s Lovelace UI is flexible out of the box, but HACS brings a treasure trove of &lt;strong&gt;custom Lovelace cards and themes&lt;/strong&gt;. With these, you can build sleek, visually appealing dashboards tailored to your preferences.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Community-Driven Innovation
&lt;/h2&gt;

&lt;p&gt;The Home Assistant community is packed with talented developers, and HACS is the go-to platform for their creations. You’ll often find cutting-edge integrations or solutions to common challenges here before they’re available in the core platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Seamless Updates
&lt;/h2&gt;

&lt;p&gt;HACS makes managing custom components simple. With its intuitive interface, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Install new integrations in a few clicks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Receive notifications when updates are available.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Update integrations directly within the Home Assistant UI.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Completely Open Source
&lt;/h2&gt;

&lt;p&gt;True to Home Assistant’s ethos, HACS is 100% open source, ensuring transparency and fostering a collaborative ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Popular HACS Integrations and Plugins
&lt;/h2&gt;

&lt;p&gt;Some standout offerings available through HACS include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mini Graph Card&lt;/strong&gt; : Create beautiful graphs for temperature, power usage, and more.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lovelace Auto-Entities&lt;/strong&gt; : Dynamically display entities on your dashboard based on filters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Garbage Collection&lt;/strong&gt; : Stay on top of your trash and recycling schedule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom Animated Weather Cards&lt;/strong&gt; : Add visually stunning weather widgets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UI Themes&lt;/strong&gt; : Transform the look and feel of your Home Assistant interface with themes like “Dark Mode” or “Google Material Design.”&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Install and Use HACS
&lt;/h2&gt;

&lt;p&gt;Installing HACS is straightforward, but it requires a few steps. Here’s a quick guide to get you started:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Prepare Home Assistant
&lt;/h2&gt;

&lt;p&gt;Before you begin, ensure you’re running Home Assistant Core on a supported platform like Docker, a Raspberry Pi, or a virtual machine.We will continue with the docker installation, which was done in the first step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;How to install Homeassistant via Docker on an Azure Linux VM&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Install HACS
&lt;/h2&gt;

&lt;p&gt;HACS installation is best done via Home Assistant’s CLI:&lt;/p&gt;

&lt;p&gt;Official Documentation: &lt;a href="https://hacs.xyz/docs/use/configuration/basic/#to-set-up-the-hacs-integration" rel="noopener noreferrer"&gt;https://hacs.xyz/docs/use/configuration/basic/#to-set-up-the-hacs-integration&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;SSH into your Home Assistant setup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Change to your config-volume, which in our case is mounted under /home/xxxUserDirxxx/homeassistant&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run the following command to install HACS:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;curl -sfSL https://get.hacs.xyz | bash -&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Integrate HACS with Home Assistant
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Once installed, restart Home Assistant.&lt;/li&gt;
&lt;/ul&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%2Fcby0utrcz7zznhilpex6.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%2Fcby0utrcz7zznhilpex6.png" alt="HACS Setup 1" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Settings &amp;gt; Integrations&lt;/strong&gt; in the Home Assistant UI.&lt;/li&gt;
&lt;/ul&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%2Ff48zjyqmu2qkq2zj1lq1.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%2Ff48zjyqmu2qkq2zj1lq1.png" alt="HACS Setup 2" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Search for “HACS” and follow the on-screen instructions to complete setup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Follow this detailed how-to for the final steps -&amp;gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Start Exploring
&lt;/h2&gt;

&lt;p&gt;After setup, HACS will appear in your sidebar. Browse and install custom integrations, themes, and plugins with just a few clicks.&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%2F2yz7m2hab3qkk1plzbd9.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%2F2yz7m2hab3qkk1plzbd9.png" alt="HACS Setup 3" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As you can see, I needed the integration “Fusion Solar” to connect my Huawei PV-Solution, and I installed the “Mobile App” as well. The next step will be installing the KNX/EIB solution so that I can interact with my home.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions about HACS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is HACS in Home Assistant?
&lt;/h3&gt;

&lt;p&gt;HACS (Home Assistant Community Store) is a community-maintained store for installing and updating custom integrations, themes, and Lovelace cards that aren’t in Home Assistant’s official integration list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is HACS official or safe to use?
&lt;/h3&gt;

&lt;p&gt;HACS isn’t an official Home Assistant project, but it’s widely used and open source. The integrations you install through it are community-made, so review a repository’s popularity and source before installing — just as you would with any third-party software.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need HACS for Home Assistant?
&lt;/h3&gt;

&lt;p&gt;No — Home Assistant works fully without it. You only need HACS once you want a custom integration, theme, or dashboard card that isn’t available in the built-in catalog.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I install HACS?
&lt;/h3&gt;

&lt;p&gt;The quickest way is the official one-line installer run from your Home Assistant CLI, then adding HACS as an integration and authorizing it with a GitHub account. See the step-by-step section above.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the must-have HACS integrations?
&lt;/h3&gt;

&lt;p&gt;Popular picks include custom Lovelace cards such as Mushroom and mini-graph-card, the Card Mod and Browser Mod tools, and device-specific integrations the core doesn’t ship — see the “Popular HACS Integrations” section above.&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>homeassistant</category>
      <category>tutorial</category>
      <category>selfhosted</category>
      <category>smarthome</category>
    </item>
    <item>
      <title>Caching a Huawei SUN2000 over Modbus for Home Assistant</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Mon, 08 Jun 2026 18:06:57 +0000</pubDate>
      <link>https://dev.clauneck.workers.dev/cloudapp_dev/caching-a-huawei-sun2000-over-modbus-for-home-assistant-59j0</link>
      <guid>https://dev.clauneck.workers.dev/cloudapp_dev/caching-a-huawei-sun2000-over-modbus-for-home-assistant-59j0</guid>
      <description>&lt;p&gt;My Huawei SDongle accepts exactly one Modbus connection at a time — but Home Assistant, my AC·THOR and evcc all want to read the inverter at once. Polling it from three clients just gave me dropped connections and gaps in my data. So I wrote a small asyncio cache server (~300 lines) that does one quiet poll of the SUN2000 and serves every client from that cached snapshot. Here's how it works, and the full code.&lt;/p&gt;

&lt;p&gt;The fault that finally pushed me into writing my own Modbus server wasn't dramatic. It was a hole. Every evening, right around the time the boiler heater kicked in, my energy dashboard in Home Assistant would flatline for a few minutes and then snap back. Not a crash — just a gap, the kind you stop noticing until you're squinting at a graph trying to work out where 0.4 kWh went.&lt;/p&gt;

&lt;h2&gt;
  
  
  One connection, and everyone wants it
&lt;/h2&gt;

&lt;p&gt;I run a &lt;strong&gt;Huawei SUN2000-8KTL-M1&lt;/strong&gt; with a LUNA2000 battery. The inverter talks to the outside world through a little SDongle, and the SDongle speaks Modbus TCP on port 502. The catch — and it's one a lot of Huawei owners walk straight into — is that the SDongle accepts exactly &lt;strong&gt;one&lt;/strong&gt; Modbus TCP connection at a time.&lt;/p&gt;

&lt;p&gt;And I had four things that wanted it: Home Assistant's Huawei Solar integration for the dashboard, the &lt;strong&gt;AC·THOR 9s&lt;/strong&gt; that dumps PV surplus into the hot-water boiler and needs a live meter reading to modulate, evcc for the wallbox, and the FusionSolar cloud. FusionSolar is the lucky one — it rides its own channel up to Huawei's servers and never touches Modbus. The other three were elbowing each other off the single slot. Whoever connected last won; the rest got connection resets, and the dashboard got that flatline.&lt;/p&gt;

&lt;h2&gt;
  
  
  First fix: a transparent proxy
&lt;/h2&gt;

&lt;p&gt;The obvious answer is a proxy: one process holds the single connection to the SDongle, every client talks to the proxy instead. I started with the &lt;a href="https://github.com/TCzerny/ha-modbusproxy" rel="noopener noreferrer"&gt;ha-modbusproxy add-on&lt;/a&gt; — point it at the SDongle, have it listen on 5502, repoint Home Assistant and the AC·THOR there.&lt;/p&gt;

&lt;p&gt;It worked. For a while. But a transparent proxy still forwards every client's read straight through to the inverter, and that surfaced a subtler problem. Modbus TCP tags each request with a transaction id, and several clients sharing one upstream connection don't coordinate those ids. Under load you can get a client receiving a response meant for someone else's request, decoding it, and quietly believing the battery sits at 7% when it's really at 70%. Rare — but wrong in the worst possible way, because it's silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that stuck: stop talking to the inverter
&lt;/h2&gt;

&lt;p&gt;So I stopped letting the clients talk to the inverter at all. Instead of forwarding reads, I poll the SDongle myself, once, on a schedule, cache every register I care about, and serve all the clients out of that cache. It's about 300 lines of asyncio Python, it runs as a systemd service on port 5502, and the inverter only ever sees one polite reader.&lt;/p&gt;

&lt;p&gt;The reader side is a list of register batches and a loop:&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;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
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37113&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 grid power (int32)
&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="c1"&gt;# ...thirteen batches in total
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# one connection, walk every batch 50 ms apart, then sleep 10 s
&lt;/span&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="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;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;v&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;v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each batch is a plain function-code-3 read. I keep 50 ms between them so I'm not rushing a device that's slower than a normal Modbus meter, and the whole sweep repeats every ten seconds. That's the only conversation the SDongle ever has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving the clients from cache
&lt;/h2&gt;

&lt;p&gt;The server side speaks just enough Modbus to be useful. A read never touches the inverter — it's answered straight from the dict, and crucially I build the response against the calling client's own header, so the transaction-id problem simply cannot happen:&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="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# read holding registers — served from cache, never the inverter
&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="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="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;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;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="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="c1"&gt;# built against THIS client's own header → no transaction-id mix-ups
&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;Writes are the exception. A write (function code 6) is usually battery control — telling the inverter to charge or discharge — and that has to reach real hardware, so I forward those straight to the SDongle and pass the result back. Reads are cached, writes are real. That one split is the whole design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually bought me
&lt;/h2&gt;

&lt;p&gt;The part I didn't expect to enjoy this much is the decoupling. A client's poll rate is now completely independent of the inverter's. Home Assistant can ask every five seconds, the AC·THOR every second, evcc whenever it feels like it — and the SDongle still sees exactly one reader, once every ten. The flatline is gone, and the AC·THOR hasn't lost its meter value since.&lt;/p&gt;

&lt;p&gt;It also gave me a clean place to hang the numbers I actually care about. On top of those cached registers I built a handful of template sensors: self-consumption sitting around &lt;strong&gt;65.9%&lt;/strong&gt; , autarky &lt;strong&gt;76.9%&lt;/strong&gt; , the battery turning in &lt;strong&gt;97.2%&lt;/strong&gt; round-trip efficiency. Those are exactly the figures the FusionSolar app never quite shows you in one place — and they're the subject of the next part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest gotcha
&lt;/h2&gt;

&lt;p&gt;A cache can go stale, and an energy dashboard that lies confidently is worse than one with an honest gap. If the SDongle drops — a firmware reboot, a flaky switch — the reader loop keeps failing and the cached values quietly age. So I log it: once the cache is older than 120 seconds a warning fires, and the retry backs off instead of hammering a device that isn't answering. Clients keep getting the last-known value, which for a power graph is the right failure mode — a held line beats a hole — but you do want to know when it's happening.&lt;/p&gt;

&lt;p&gt;If you run Home Assistant on a VM like I do — I wrote earlier about &lt;a href="https://www.cloudapp.dev/home-assistant-how-to-install-via-docker-on-an-azure-linux-vm" rel="noopener noreferrer"&gt;getting it onto an Azure Linux box&lt;/a&gt; — dropping a 300-line Python service next to it costs almost nothing, and it's been the single most stable part of my solar setup ever since. Next part: turning those cached registers into the autarky and self-consumption numbers that actually tell you whether the battery was worth buying.&lt;/p&gt;

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