<?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: paw2paw</title>
    <description>The latest articles on DEV Community by paw2paw (@paw2paw).</description>
    <link>https://dev.to/paw2paw</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%2F4098816%2Fda8bb9f0-2a9b-49be-baf1-95cefdcd77a3.png</url>
      <title>DEV Community: paw2paw</title>
      <link>https://dev.to/paw2paw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paw2paw"/>
    <language>en</language>
    <item>
      <title>Three years of a hot tub lying to Home Assistant — and the 200 lines that finally shut it up</title>
      <dc:creator>paw2paw</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:11:23 +0000</pubDate>
      <link>https://dev.to/paw2paw/three-years-of-a-hot-tub-lying-to-home-assistant-and-the-200-lines-that-finally-shut-it-up-k0i</link>
      <guid>https://dev.to/paw2paw/three-years-of-a-hot-tub-lying-to-home-assistant-and-the-200-lines-that-finally-shut-it-up-k0i</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.tourl"&gt;&lt;/a&gt;Three years ago I retrofitted a &lt;strong&gt;Balboa BWA Wi-Fi Module (part 50350)&lt;/strong&gt; to my hot tub — a £270 aftermarket board that turns any BP-controlled Balboa spa into a TCP-speaking device on the local network. Home Assistant's stock &lt;code&gt;balboa&lt;/code&gt; integration talks to it directly via &lt;a href="https://github.com/garbled1/pybalboa" rel="noopener noreferrer"&gt;&lt;code&gt;pybalboa&lt;/code&gt;&lt;/a&gt;. The setup takes about 90 seconds and works out of the box.&lt;/p&gt;

&lt;p&gt;Right up until it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pain
&lt;/h2&gt;

&lt;p&gt;For three years I lived with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;climate.spa&lt;/code&gt; showing &lt;code&gt;Unavailable&lt;/code&gt; in Lovelace for hours at a stretch, seemingly at random&lt;/li&gt;
&lt;li&gt;HA logs full of &lt;code&gt;TimeoutError&lt;/code&gt; and &lt;code&gt;ConnectionResetError&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rare but genuinely alarming HA restarts triggered by memory pressure while the integration was reading from a hung socket&lt;/li&gt;
&lt;li&gt;Automations firing into the void because the spa entity had been stale for 40 minutes and nobody knew&lt;/li&gt;
&lt;li&gt;The classic "is it my Wi-Fi?" rabbit hole — nope, RSSI is −44 dBm, the tub is 3 metres from the AP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I put up with it because there was no supported alternative that didn't involve opening the spa panel, voiding warranty, and soldering to the RS485 bus (which is a great fix — see &lt;a href="https://github.com/HyperActiveJ/SundanceJacuzzi_HomeAssistant_TCP_RS485" rel="noopener noreferrer"&gt;HyperActiveJ's ESP32 bridge project&lt;/a&gt; — but not one I wanted to attempt in February with the tub full of water).&lt;/p&gt;

&lt;p&gt;Then I got annoyed enough to actually measure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;I wrote a small soak harness in Python — a script that opens a TCP connection to the module, tracks whether messages are arriving, logs every state transition, and produces a CSV I can analyse. Critically, it uses &lt;strong&gt;the same &lt;code&gt;connection.py&lt;/code&gt; module the HA integration would eventually use&lt;/strong&gt;, so what I measure on my Mac is exactly what runs in Home Assistant later.&lt;/p&gt;

&lt;p&gt;I ran it against my spa for 14.75 hours on excellent Wi-Fi. The results were worse than I expected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Effective uptime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33.85 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total disconnects&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disconnects classified as "stale socket"&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41 of 42&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak silent window&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;104 minutes&lt;/strong&gt; at 05:17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal strength during test&lt;/td&gt;
&lt;td&gt;−44 dBm (excellent)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The module is silently unresponsive &lt;strong&gt;two-thirds of the time&lt;/strong&gt;. Overnight it can go dark for over an hour and a half. This is not a Wi-Fi problem, not a channel problem, not a router problem — the module firmware just stops sending data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: zombie sockets
&lt;/h2&gt;

&lt;p&gt;Here's what "stale socket" means in practice.&lt;/p&gt;

&lt;p&gt;A healthy Balboa TCP session looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client               Module
  |                    |
  |---- SYN ---------&amp;gt;|
  |&amp;lt;--- SYN-ACK ------|
  |---- ACK ---------&amp;gt;|   (connection established)
  |                    |
  |&amp;lt;-- status frame --|   (module streams 1 msg/sec)
  |&amp;lt;-- status frame --|
  |&amp;lt;-- status frame --|
  |         ...        |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what the 50350 does after ~30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  |&amp;lt;-- status frame --|
  |&amp;lt;-- status frame --|
  |&amp;lt;-- status frame --|
  |                    |   ← module goes silent, socket stays open
  |                    |
  |                    |
  |                    |   ← 40 minutes later, still nothing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module never sends a FIN. Never sends an RST. Never closes the connection. From &lt;code&gt;pybalboa&lt;/code&gt;'s perspective, the TCP connection is fine — &lt;code&gt;socket.recv()&lt;/code&gt; just blocks forever waiting for data that will never arrive.&lt;/p&gt;

&lt;p&gt;Multiply by a few hours of unattended operation and the integration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Freezes its entity states on their last known values (the temperature shown when I unplugged the tub? The reading from 40 minutes ago)&lt;/li&gt;
&lt;li&gt;Piles up half-processed read buffers in memory&lt;/li&gt;
&lt;li&gt;Occasionally trips HA's memory watchdog and restarts the whole supervisor&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Once the failure mode is understood, the fix is well-known distributed-systems hygiene: &lt;strong&gt;you cannot trust the TCP layer to tell you the peer is alive.&lt;/strong&gt; You have to prove liveness at the application layer.&lt;/p&gt;

&lt;p&gt;The pattern is a supervised connection with three layers of defence:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Heartbeat / staleness detection
&lt;/h3&gt;

&lt;p&gt;The Balboa protocol streams one status frame per second. Any silence longer than N seconds means the module has hung, regardless of what TCP thinks.&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;_heartbeat_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_running&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heartbeat_interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;silence&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;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_frame_at&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;silence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale_after&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Link stale (%.1fs &amp;gt; %.1fs) — tearing down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;silence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale_after&lt;/span&gt;&lt;span class="p"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_teardown_and_reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Zombie-socket rejection at connect time
&lt;/h3&gt;

&lt;p&gt;A fresh connection where TCP handshake completes but no spa data arrives within the connect timeout isn't a success — it's a zombie. Reject it.&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;_connect_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Wait for first real spa frame before declaring success
&lt;/span&gt;        &lt;span class="n"&gt;first_frame&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_read_frame&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;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;wait_closed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NoSpaDataError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TCP ok but no spa data — zombie socket&lt;/span&gt;&lt;span class="sh"&gt;"&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;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;first_frame&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Exponential backoff with cap
&lt;/h3&gt;

&lt;p&gt;Retrying every second when the module is in a 104-minute dead zone is pointless — you just fill your logs. Back off, but not to infinity:&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;def&lt;/span&gt; &lt;span class="nf"&gt;_next_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backoff_initial&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backoff_factor&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backoff_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defaults: 5s → 10s → 20s → 40s → 80s → 120s (capped). All tunable in the HA UI, hot-applied without a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Wrapping &lt;code&gt;pybalboa&lt;/code&gt; with a &lt;code&gt;SpaConnectionManager&lt;/code&gt; that owns the connection lifecycle, and having the HA integration talk to &lt;em&gt;that&lt;/em&gt; instead of directly to the socket, turned three years of chaos into a well-behaved integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No more crashes / memory leaks&lt;/li&gt;
&lt;li&gt;Entity states flip to &lt;code&gt;Unavailable&lt;/code&gt; within 30 seconds of the module going silent (was: never)&lt;/li&gt;
&lt;li&gt;Automations gated on &lt;code&gt;binary_sensor.spa_reachable&lt;/code&gt; fire cleanly instead of into the void&lt;/li&gt;
&lt;li&gt;A rolling &lt;code&gt;sensor.spa_uptime_rolling&lt;/code&gt; shows exactly how flaky the module is (spoiler: still 34%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note what this does not do:&lt;/strong&gt; it doesn't fix the module's firmware. The 50350 still goes silent for 30 s to 100+ min at a time. Nothing running in Home Assistant can change that — the bug is on the other side of the wire. What this delivers is a &lt;strong&gt;shock absorber&lt;/strong&gt;: the road is still bumpy, but the car stays on it and the passengers don't get thrown out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native HA design as a forcing function
&lt;/h2&gt;

&lt;p&gt;The interesting side effect of writing a "properly HA-native" integration is that the platform's own type system nudges you toward good ergonomics. Instead of shoving everything into &lt;code&gt;sensor&lt;/code&gt; entities, I got to use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;HA entity type&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Filter cycle start/end times&lt;/td&gt;
&lt;td&gt;&lt;code&gt;time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User can edit them directly from the tile — no automation glue needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heat mode (Ready / Rest)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;select&lt;/code&gt; with options&lt;/td&gt;
&lt;td&gt;Native dropdown, translatable, discoverable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest fault code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Enters the native HA logbook with structured attributes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Is the spa reachable enough to fire automations?"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;binary_sensor&lt;/code&gt; with &lt;code&gt;for: 30s&lt;/code&gt; gate&lt;/td&gt;
&lt;td&gt;Direct automation &lt;code&gt;condition&lt;/code&gt; — no template needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sensor&lt;/code&gt; with &lt;code&gt;SensorDeviceClass.ENUM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Automatic long-term statistics + colour-coded history graph&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The connection-health surface alone is worth writing: exposing &lt;code&gt;connection_state&lt;/code&gt;, &lt;code&gt;uptime_rolling&lt;/code&gt;, &lt;code&gt;connect_latency&lt;/code&gt;, &lt;code&gt;next_attempt_at&lt;/code&gt;, &lt;code&gt;connections_lost&lt;/code&gt;, and a fault &lt;code&gt;event&lt;/code&gt; entity means you can &lt;em&gt;see&lt;/em&gt; the module's misbehaviour instead of guessing at it. In three years of the stock integration I never knew whether "unavailable spa" was a Wi-Fi blip, a HA bug, or the module — because none of that state was visible. Now it's a live graph on my dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, break it, improve it
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/paw2paw/balboa_robust" rel="noopener noreferrer"&gt;github.com/paw2paw/balboa_robust&lt;/a&gt; — MIT licensed.&lt;/p&gt;

&lt;p&gt;Install via HACS as a custom repository. Not a replacement for the stock &lt;code&gt;balboa&lt;/code&gt; integration — if your module is healthy, keep using stock, it's maintained by the HA core team and battle-tested by thousands of users. This one only earns its keep if you're seeing the specific 33%-uptime, stale-socket, "temperature-still-shows-when-unplugged" pattern.&lt;/p&gt;

&lt;p&gt;Two things I'd love from readers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirmation&lt;/strong&gt; — if you own a retrofitted 50350 and are seeing &lt;code&gt;Unavailable&lt;/code&gt; several times a day, does this pattern generalise? File an issue with your soak numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upstream&lt;/strong&gt; — the medium-term goal is to distill the heartbeat / zombie-socket / backoff patches back into a &lt;a href="https://github.com/garbled1/pybalboa" rel="noopener noreferrer"&gt;&lt;code&gt;pybalboa&lt;/code&gt;&lt;/a&gt; PR so the stock integration benefits too. If you know that codebase, DM me.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;connection.py&lt;/code&gt; module has zero HA imports on purpose — the resilience layer is a plain Python state machine that anyone can build on. An MQTT bridge that exposes the spa to Node-RED / OpenHAB / anything-that-speaks-MQTT is a weekend's work on top. If that's interesting to you, that's next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix: the soak harness
&lt;/h2&gt;

&lt;p&gt;The single most useful thing I built was not the integration. It was the ~150-line script that ran overnight and produced this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== SOAK SUMMARY ===
Duration:           14h 45m
Effective uptime:   33.85%
Total disconnects:  42
Stale-socket:       41
Clean disconnects:  1
Longest outage:     104m at 05:17
Signal (avg RSSI):  -44 dBm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before that data existed, this bug was "sometimes the spa is unavailable." After that data existed, it was "the module is unresponsive two-thirds of the time and the failure mode is 98% one specific pattern." That's the difference between complaining and shipping a fix.&lt;/p&gt;

&lt;p&gt;If you're debugging any flaky IoT device on your network, &lt;strong&gt;write the soak harness first.&lt;/strong&gt; It'll pay for itself in the first hour.&lt;/p&gt;

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