<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Bernhart</title>
    <description>The latest articles on DEV Community by Michael Bernhart (@cloudapp_dev).</description>
    <link>https://dev.to/cloudapp_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3974608%2Fc22d5e7e-7665-4ecb-b10b-4d4bb29e2c04.png</url>
      <title>DEV Community: Michael Bernhart</title>
      <link>https://dev.to/cloudapp_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudapp_dev"/>
    <language>en</language>
    <item>
      <title>What AI Crawlers Actually Do to a Small Blog: 9 Days of Logs</title>
      <dc:creator>Michael Bernhart</dc:creator>
      <pubDate>Sun, 28 Jun 2026 14:03:43 +0000</pubDate>
      <link>https://dev.to/cloudapp_dev/what-ai-crawlers-actually-do-to-a-small-blog-9-days-of-logs-4nf0</link>
      <guid>https://dev.to/cloudapp_dev/what-ai-crawlers-actually-do-to-a-small-blog-9-days-of-logs-4nf0</guid>
      <description>&lt;p&gt;I run a small Home Assistant / self-hosting blog. On a normal day a few dozen humans show up. So when I finally grepped my nginx logs for AI crawlers, the number made me stop: in nine days, AI bots hit the site &lt;strong&gt;18,209 times&lt;/strong&gt;. On a blog this size, the machines reading me now outnumber the people.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

&lt;p&gt;The SUN2000's SDongle allows only &lt;strong&gt;one&lt;/strong&gt; concurrent Modbus connection. If HA, evcc and an AC·THOR all hit it directly, they block each other. That's why my Modbus hub doesn't point at the SDongle IP but at &lt;strong&gt;127.0.0.1:5502&lt;/strong&gt; — the local caching proxy that polls the SDongle once and serves the result to any number of clients. Why and how that's needed is covered in the &lt;a href="https://www.cloudapp.dev/en-US/caching-huawei-sun2000-modbus-home-assistant" rel="noopener noreferrer"&gt;Modbus caching basics post&lt;/a&gt;. If you have only a single client, you can point host/port straight at the SDongle instead — the sensors underneath stay identical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_inverter&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5502&lt;/span&gt;
  &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;sensors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Cumulative&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Yield"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_pv_cumulative_yield&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32106&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uint32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;unit_of_measurement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kWh"&lt;/span&gt;
      &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;With the grid registers the classic mistake is the data_type. Grid values can come as &lt;strong&gt;signed int32&lt;/strong&gt; depending on firmware — if you accidentally read them as uint32, a value just above or below zero flips into a huge number (two's complement). So read them as int32 and the sign and magnitude line up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Exported"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_grid_exported_energy&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37119&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;unit_of_measurement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kWh"&lt;/span&gt;
      &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Imported"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;huawei_grid_imported_energy&lt;/span&gt;
      &lt;span class="na"&gt;slave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37121&lt;/span&gt;
      &lt;span class="na"&gt;input_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;holding&lt;/span&gt;
      &lt;span class="na"&gt;data_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;unit_of_measurement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kWh"&lt;/span&gt;
      &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;energy&lt;/span&gt;
      &lt;span class="na"&gt;state_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;total_increasing&lt;/span&gt;
      &lt;span class="na"&gt;scan_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;I parameterize the whole proxy through a handful of constants at the top of the file. In the normal case you only change your SDongle's IP and maybe the listen port. Everything else fits a standard Huawei installation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# === Configuration ===
&lt;/span&gt;&lt;span class="n"&gt;SDONGLE_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.0.2.10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- deine Huawei SDongle IP (RFC 5737 Beispiel)
&lt;/span&gt;&lt;span class="n"&gt;SDONGLE_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;
&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

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

&lt;/div&gt;



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

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

&lt;p&gt;The SDongle is slow, and every single read costs a full TCP roundtrip. Instead of querying each register individually, I read contiguous blocks in one go. Modbus allows up to 125 registers per FC3 read; I group the registers I need into a few batches along the natural gaps in the Huawei map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REGISTER_BATCHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32016&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# PV string 1/2 voltage + current
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32064&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Input power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Active power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32106&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Cumulative energy yield (uint32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32114&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Daily energy yield (uint32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37760&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery SOC
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37765&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery power (int32)
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;37780&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# Battery total charge/discharge + daily
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;This is where it gets interesting. A Modbus-TCP frame is the 7-byte MBAP header (transaction ID, protocol ID, length, unit ID) plus the PDU (function code + payload). With &lt;strong&gt;struct.pack&lt;/strong&gt; I build the request frame, write it to the dongle, and unpack the response again. The format string &lt;strong&gt;"&amp;gt;HHHBBHH"&lt;/strong&gt; encodes exactly that structure: three big-endian uint16 for the MBAP head, then unit, function code and the two uint16 for start address and count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHBBHH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_fc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHBBB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp_fc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Exception
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reg_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;cache_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg_count&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;register_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg_addr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;byte_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;BB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp_pdu&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reg_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_header&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;resp_pdu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Holding it all together is a single long-running task that polls the dongle in a loop. As long as everything is fine it runs at the normal 10-second cadence. If a poll fails, it goes into a shorter retry, and if the cache gets too old, it writes a warning to the log.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;stale warning&lt;/strong&gt; after 120 seconds is my early-warning system: when it shows up in the log I know the dongle has stopped answering before the clients even start showing weird values. I deliberately kept the cache holding the last valid values on failure rather than dropping to zero — otherwise every dongle hiccup would push a PV power of 0 W to all clients and trigger false alarms and broken statistics in Home Assistant.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

</description>
      <category>docker</category>
      <category>tutorial</category>
      <category>homeassistant</category>
      <category>selfhosted</category>
    </item>
    <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.to/cloudapp_dev/control-an-acthor-9s-from-home-assistant-when-modbus-writes-are-blocked-the-my-pv-cloud-api-1kmh</link>
      <guid>https://dev.to/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.to/cloudapp_dev/detect-a-failing-pv-string-in-home-assistant-shading-dead-module-or-loose-mc4-3f5b</link>
      <guid>https://dev.to/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.to/cloudapp_dev/the-best-hacs-integrations-for-home-assistant-my-must-haves-4n0j</link>
      <guid>https://dev.to/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.to/cloudapp_dev/an-open-source-seo-geo-audit-toolkit-in-plain-node-4foc</link>
      <guid>https://dev.to/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.to/cloudapp_dev/how-to-install-hacs-in-home-assistant-step-by-step-26lc</link>
      <guid>https://dev.to/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.to/cloudapp_dev/why-the-home-assistant-community-store-hacs-is-a-must-have-for-every-ha-user-omg</link>
      <guid>https://dev.to/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.to/cloudapp_dev/caching-a-huawei-sun2000-over-modbus-for-home-assistant-59j0</link>
      <guid>https://dev.to/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>
