<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Untitled Publication]]></title><description><![CDATA[Untitled Publication]]></description><link>https://victorchang.codes</link><generator>RSS for Node</generator><lastBuildDate>Fri, 17 Apr 2026 16:24:43 GMT</lastBuildDate><atom:link href="https://victorchang.codes/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Make Your Ceiling Fans Smart with Home Assistant - Round 2]]></title><description><![CDATA[Following up on my post about controlling ceiling fans, let’s address some of the shortcomings, which are:

ESPHome 2025.2 removed support for dbuezas’ CC1101 custom component. We need a way forward.

IoT controls generally assume you can specificall...]]></description><link>https://victorchang.codes/controlling-ceiling-fans-with-home-assistant-part-2</link><guid isPermaLink="true">https://victorchang.codes/controlling-ceiling-fans-with-home-assistant-part-2</guid><category><![CDATA[Home Assistant]]></category><category><![CDATA[Ceiling Fan ]]></category><category><![CDATA[esphome]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[cc1101]]></category><category><![CDATA[fan]]></category><category><![CDATA[automation]]></category><category><![CDATA[scripts]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Thu, 31 Jul 2025 21:36:08 GMT</pubDate><content:encoded><![CDATA[<p>Following up on my post about <a target="_blank" href="https://victorchang.codes/controlling-ceiling-fans-with-home-assistant">controlling ceiling fans</a>, let’s address some of the shortcomings, which are:</p>
<ol>
<li><p>ESPHome 2025.2 removed support for <a target="_blank" href="https://github.com/dbuezas/esphome-cc1101/issues/34">dbuezas’ CC1101 custom component</a>. We need a way forward.</p>
</li>
<li><p>IoT controls generally assume you can specifically set a dimmer/speed setting for things (such as “30 percent”), but how do you map that to a fan with only up/down buttons?</p>
</li>
<li><p>Using Google Home (or Alexa, etc.) is more convenient, but can we expose these fans to them without an <em>additional</em> hop to the cloud?</p>
</li>
<li><p>What if the fan is switched off at the wall or via its original remote? How can Home Assistant learn its new state?</p>
</li>
</ol>
<p><em>Caveat: The solutions below fit my hardware and use-case; YMMV.</em></p>
<p>And with that said, here is how I solved each of the shortcomings!</p>
<h2 id="heading-1-using-the-newer-cc1101-implementation">1. Using the newer CC1101 implementation</h2>
<p>Since the previous CC1101 implementation is no longer supported as of February 2025, I switched to using an <a target="_blank" href="https://github.com/esphome/esphome/pull/6300">unmerged PR</a> (lol). But the configuration syntax is very different, and the documentation does not explain how to broadcast a timing array (the format that I have for my signals). Here is the syntax that works for me.</p>
<h3 id="heading-optional-add-a-sub-device">(Optional) Add a sub-device:</h3>
<p>This is a new feature added in ESPHome 2025.7.</p>
<pre><code class="lang-yaml">  <span class="hljs-attr">devices:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">radio_control_device</span>
      <span class="hljs-attr">name:</span> <span class="hljs-string">"Radio Control"</span>
</code></pre>
<h3 id="heading-use-the-prs-externalcomponent">Use the PR’s <code>external_component</code>:</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">external_components:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">source:</span> <span class="hljs-string">github://pr#6300</span>
    <span class="hljs-attr">components:</span> [ <span class="hljs-string">cc1101</span> ]
</code></pre>
<h3 id="heading-set-up-with-the-gpio-pins-that-were-used">Set up with the GPIO pins that were used</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">spi:</span>
  <span class="hljs-attr">clk_pin:</span> <span class="hljs-string">GPIO18</span>
  <span class="hljs-attr">miso_pin:</span> <span class="hljs-string">GPIO19</span>
  <span class="hljs-attr">mosi_pin:</span> <span class="hljs-string">GPIO23</span>

<span class="hljs-attr">cc1101:</span>
  <span class="hljs-attr">id:</span> <span class="hljs-string">transceiver</span>
  <span class="hljs-attr">cs_pin:</span> <span class="hljs-string">GPIO5</span>

<span class="hljs-attr">remote_transmitter:</span>
  <span class="hljs-attr">id:</span> <span class="hljs-string">cc1101_transmitter</span>
  <span class="hljs-attr">pin:</span> <span class="hljs-string">GPIO33</span> <span class="hljs-comment"># This is GDO0</span>
  <span class="hljs-attr">carrier_duty_percent:</span> <span class="hljs-number">100</span><span class="hljs-string">%</span>
  <span class="hljs-attr">on_transmit:</span>
    <span class="hljs-attr">then:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">cc1101.begin_tx:</span> <span class="hljs-string">transceiver</span>
  <span class="hljs-attr">on_complete:</span>
    <span class="hljs-attr">then:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">cc1101.end_tx:</span> <span class="hljs-string">transceiver</span>
</code></pre>
<h3 id="heading-allow-setting-the-broadcast-frequency-per-broadcast">Allow setting the broadcast frequency per broadcast</h3>
<p>I need to broadcast at both 433.92MHz <strong>and</strong> 350MHz, so I can’t let it default to 433.92MHz. The way you can set the frequency on-demand is to create a <code>number</code> mapping to a field defined by the CC1101 implementation:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">number:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">cc1101</span>
    <span class="hljs-attr">tuner:</span>
      <span class="hljs-attr">frequency:</span>
        <span class="hljs-attr">id:</span> <span class="hljs-string">tuner_frequency</span>
        <span class="hljs-attr">name:</span> <span class="hljs-string">"Tuner Frequency (kHz)"</span>
</code></pre>
<p>I then create a wrapper script to handle all command broadcasts:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">script:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">radio_tx</span>
    <span class="hljs-attr">mode:</span> <span class="hljs-string">queued</span>  <span class="hljs-comment"># Enforce that transmissions don't overlap.</span>
    <span class="hljs-attr">parameters:</span>
      <span class="hljs-attr">freq_khz:</span> <span class="hljs-string">int</span>  <span class="hljs-comment"># The frequency to broadcast at.</span>
      <span class="hljs-attr">code:</span> <span class="hljs-string">int[]</span>  <span class="hljs-comment"># The signal.</span>
      <span class="hljs-attr">trailing_delay:</span> <span class="hljs-string">bool</span>  <span class="hljs-comment"># Whether to have a gap before broadcasting the next signal.</span>
    <span class="hljs-attr">then:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">number.set:</span>  <span class="hljs-comment"># Set the broadcast frequency</span>
          <span class="hljs-attr">id:</span> <span class="hljs-string">tuner_frequency</span>
          <span class="hljs-attr">value:</span> <span class="hljs-type">!lambda</span> <span class="hljs-string">'return freq_khz;'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">3ms</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
          esphome::remote_base::RawTimings timings;
          timings.reserve(code.size());
          for (auto v : code)
            timings.push_back(static_cast&lt;int32_t&gt;(v));
          auto call = id(cc1101_transmitter).transmit();
          call.get_data()-&gt;set_data(timings);
          call.set_send_times(2);
          call.set_send_wait(10);
          call.perform();
</span>      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-type">!lambda</span> <span class="hljs-string">'return trailing_delay ? 700 : 0;'</span>
</code></pre>
<p>The <code>mode: queued</code> means I can send any number of commands and the script will make sure each signal is sent in the correct order, with an adequate trailing delay for the receiver to not mix it in with the next signal.</p>
<p>Unfortunately, the script mechanism only supports sending <code>bool</code>, <code>int</code>, <code>float</code>, <code>string</code>, and array variations of each. The passed in <code>code</code> is not technically the data type that <code>set_data()</code> expects, so we need to first cast the entire array into a new array.</p>
<h3 id="heading-individual-signal-commands">Individual signal commands</h3>
<p>The rest is to just define the actual signals that the fan supports. For example:</p>
<pre><code class="lang-yaml">  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_light</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:ceiling-fan-light"</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Light</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
          std::vector&lt;int&gt; timings{461,-356,425,-356,469,-352,441,-354,441,-350,441,-354,445,-354,445,-356,443,-356,443,-352,447,-356,445,-5176,843,-346,425,-788,417,-780,837,-364,835,-346,447,-742,869,-324,843,-386,419,-752,477,-744,415,-788,829,-384,401,-766,859,-348,439,-758,855,-346,835,-378,429,-754,441,-778,431,-746,427,-776,437,-746,441,-766,449,-750,841,-374,831,-348,841,-386,817,-350,467,-744,443,-750,849,-350,439,-774,837,-350,795,-454,341,-852,385,-812,377,-820,383,-826,343,-832,387,-824,377,-798,421,-782,409,-808,819,-380,793,-384,425,-778,411,-804,415,-760,415,-802,413,-776,433,-778,419,-746,441,-778,849,-324,847,-384,425,-772,443,-740,839,-362,849,-352,847,-354,851,-352,823,-380,837,-352,823,-386,835,-354,429,-25634,429,-352,463,-350,439,-350,409,-420,407,-390,375,-424,373,-422,409,-392,373,-424,409,-390,411,-390,409,-5184,805,-380,423,-792,411,-798,821,-380,797,-362,453,-776,801,-386,847,-352,457,-752,431,-754,425,-774,831,-382,429,-774,823,-380,415,-780,815,-372,839,-364,413,-784,443,-744,439,-778,411,-782,445,-744,451,-752,445,-740,873,-332,855,-354,853,-326,841,-386,421,-780,441,-744,843,-358,449,-750,841,-366,839,-344,427,-768,441,-780,431,-746,449,-752,459,-744,449,-752,425,-782,427,-776,431,-772,815,-356,839,-384,423,-752,475,-744,417,-782,445,-748,415,-786,447,-750,451,-754,433,-746,853,-356,859,-346,445,-746,439,-774,839,-356,849,-352,825,-358,861,-352,823,-362,845,-384,823,-364,805,-378,449};
          id(radio_tx).execute(433920, timings, true);
</span>    <span class="hljs-attr">device_id:</span> <span class="hljs-string">radio_control_device</span>
</code></pre>
<p>Note: The vector of positive and negative numbers denotes the amount of time it should broadcast nothing (negative values) and the amount of time it should broadcast a pulse (positive values).</p>
<h2 id="heading-2-create-a-wrapper-around-the-updown-buttons">2. Create a wrapper around the up/down buttons</h2>
<p>Most trivially, you could create virtual buttons for Levels 1/2/3 (let’s assume you have 3 levels, but you can expand this to N levels) by just sending the relevant number of “up” signals. But the main problem is not knowing what the current fan level is.</p>
<h3 id="heading-entering-a-known-state">Entering a known state</h3>
<p>We need to first tell the fan to turn off before we can send the appropriate number of “up” commands. At least for my fan, if you spam “down”, it should lower the fan to the off state. But for a 3-level fan, that means you need to send three “down” signals just to turn the fan off. We can reduce this down to 2 commands by taking advantage of the fan on/off “toggle” button as well, which will turn the fan off if it was on, or on if it was off. It kind of has the same issue where we don’t know if the fan was on or not, but sending an “up” will always turn the fan on. So, we can create a virtual “off” button by making it send “up” and then “toggle”. This is acceptable because the toggle turns the fan off before the fan can really react to the extraneous “up” command.</p>
<h3 id="heading-esphome-yaml">ESPHome YAML</h3>
<p>Here are the virtual buttons for setting my office fan to off and to speeds 1-3. Note for the 3rd/highest level, you can skip the “off” procedure and just spam “up”.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">button:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_fan_off</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Fan</span> <span class="hljs-string">Off</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:fan-off"</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span> <span class="hljs-comment"># We don't know the state. Turn it on beforehand.</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan</span>    <span class="hljs-comment"># Now toggle it off.</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">radio_control_device</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_fan_speed_1</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:fan-speed-1"</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Fan</span> <span class="hljs-string">Speed</span> <span class="hljs-number">1</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_off</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">10ms</span> <span class="hljs-comment"># This yields to the system to reduce perceived runtime according to "Component web_server took a long time for an operation" warnings.</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">radio_control_device</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_fan_speed_2</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Fan</span> <span class="hljs-string">Speed</span> <span class="hljs-number">2</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:fan-speed-2"</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_off</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">10ms</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">10ms</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">radio_control_device</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_fan_speed_3</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Fan</span> <span class="hljs-string">Speed</span> <span class="hljs-number">3</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:fan-speed-3"</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">10ms</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">10ms</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">button.press:</span> <span class="hljs-string">office_fan_up</span>
</code></pre>
<h2 id="heading-3-expose-home-assistant-entities-as-matter-devices">3. Expose Home Assistant entities as Matter devices</h2>
<p>We’ll use <a target="_blank" href="https://github.com/t0bst4r/home-assistant-matter-hub">tobst4r’s Matter Hub</a> to expose the fans as Matter devices to services like Google Home and Alexa. But first, we need to create a HA-native fan entity out of the signals that we can send.</p>
<h3 id="heading-fan-configuration">Fan Configuration</h3>
<p>This goes into Home Assistant’s <code>configuration.yaml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">fan:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">fans:</span>
      <span class="hljs-attr">office_fan:</span>
        <span class="hljs-attr">unique_id:</span> <span class="hljs-string">42febbf9-da5d-4e2b-8001-9a10bf2e12b5</span>
        <span class="hljs-attr">friendly_name:</span> <span class="hljs-string">"Office Fan"</span>
        <span class="hljs-attr">turn_on:</span>
          <span class="hljs-attr">service:</span> <span class="hljs-string">script.turn_on</span> <span class="hljs-comment"># Does not block on the script returning</span>
          <span class="hljs-attr">target:</span>
            <span class="hljs-attr">entity_id:</span> <span class="hljs-string">script.set_office_fan_speed</span>
          <span class="hljs-attr">data:</span>
            <span class="hljs-attr">variables:</span>
              <span class="hljs-attr">percentage:</span> <span class="hljs-number">50</span>
        <span class="hljs-attr">turn_off:</span>
          <span class="hljs-attr">action:</span> <span class="hljs-string">button.press</span>
          <span class="hljs-attr">target:</span>
            <span class="hljs-attr">entity_id:</span> <span class="hljs-string">button.office_fan_off</span>
        <span class="hljs-attr">set_percentage:</span>
          <span class="hljs-attr">service:</span> <span class="hljs-string">script.turn_on</span>
          <span class="hljs-attr">target:</span>
            <span class="hljs-attr">entity_id:</span> <span class="hljs-string">script.set_office_fan_speed</span>
          <span class="hljs-attr">data:</span>
            <span class="hljs-attr">variables:</span>
              <span class="hljs-attr">percentage:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ percentage }}</span>"</span>
</code></pre>
<p>This maps the fan’s “turn on” function to setting the fan to a 50% fan level, which maps to level 2 in the script below. The “turn off” function will call the <code>office_fan_off</code> wrapper that I defined in part 1.</p>
<p>Note that both <code>turn_on</code> and <code>set_percentage</code> will call <code>script.set_office_fan_speed</code> in a non-blocking manner, which is necessary because of some debouncing we have to do. More on that later.</p>
<p>And then this script goes into <code>scripts.yaml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">set_office_fan_speed:</span>
  <span class="hljs-attr">alias:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Fan</span> <span class="hljs-string">Speed</span> <span class="hljs-string">Setting</span>
  <span class="hljs-attr">mode:</span> <span class="hljs-string">restart</span>
  <span class="hljs-attr">sequence:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span>
        <span class="hljs-attr">milliseconds:</span> <span class="hljs-number">500</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">service:</span> <span class="hljs-string">button.press</span>
      <span class="hljs-attr">target:</span>
        <span class="hljs-attr">entity_id:</span> <span class="hljs-string">&gt;
          {% if percentage &lt;= 5 %}
            button.office_fan_off
          {% elif percentage &lt;= 40 %}
            button.office_fan_speed_1
          {% elif percentage &lt;= 70 %}
            button.office_fan_speed_2
          {% elif percentage &lt;= 100 %}
            button.office_fan_speed_3
          {% endif %}</span>
</code></pre>
<p>The script must include the <code>mode: restart</code> and the <code>delay</code> because there are two quirks we need to handle:</p>
<ol>
<li><p>Google Home (don’t know about others) lets you drag a slider to set the fan percentage, but it will incessantly send dozens of intermediate percentages while you’re actively sliding. Each of these events will cause the script to be called and signals to be sent.</p>
</li>
<li><p>Home Assistant will first call <code>turn_on</code> and then <code>set_percentage</code> immediately after. This also results in duplicate calls to the script.</p>
</li>
</ol>
<p>In both cases, we are sending too many signals to the fan. We need to somehow ignore all the previous calls and just send out the signal for the very last call. So, <code>mode: restart</code> says that the script will throw out its current run if another call comes in before it finishes. We then add a 500ms delay to the script execution so that it takes long enough for the extraneous calls to come in during the execution. The result is the script gets restarted a bunch of times before finally executing only the last call.</p>
<p>The reason the fan configuration uses <code>service: script.turn_on</code> syntax is because this syntax makes the calls non-blocking. Otherwise, in the second case, <code>turn_on</code>'s call to the script will wait until the script returns (including the delay) before it calls <code>set_percentage</code>. And this means the fan will first be set to 50% before being set to the actual level you wanted.</p>
<h3 id="heading-exposing-to-matter">Exposing to Matter</h3>
<p>Go to the <a target="_blank" href="https://github.com/t0bst4r/home-assistant-matter-hub">Home-Assistant-Matter-Hub</a> web UI and edit your Matter bridge to include <code>fans.office_fan</code> like so:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753772010980/79f5f960-fd1d-4270-818a-6a59285998a8.png" alt class="image--center mx-auto" /></p>
<p>It will then be exposed through Matter as a Fan type:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753772154761/1595efa8-dc83-4f6c-847b-50fa8100db9f.png" alt class="image--center mx-auto" /></p>
<p>Provided you paired the Matter Hub “device” to your smart home provider of choice (Google, Alexa, etc.), the fan should show up on their platform as well.</p>
<h2 id="heading-4-knowing-the-fan-state-at-all-times">4. Knowing the fan state at all times</h2>
<p>If you control the fan outside of Home Assistant/Matter, then HA doesn’t know the fan’s current state. It could be that the fan was turned off but we still thought it was set to level 2. This isn’t a huge issue, but it means that if you want to turn the fan back on, you first have to tell HA to turn the fan “off” (from “level 2”) and then back on. What if we could add something to let us glean the fan’s state?</p>
<p>Enter the <a target="_blank" href="https://us.shelly.com/products/shelly-pm-mini-gen3">Shelly PM Mini</a>. It sits between the fan and the house wiring and measures the wattage of whatever it’s hooked up to. This can be installed in the base (the part that touches the ceiling) of the ceiling fan, and has excellent integration with Home Assistant.</p>
<p>In this case, we have to be lucky that each possible fan state has a unique power usage. For me, there are 7 (8 including Off) states that need to not overlap:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Fan Level</strong></td><td><strong>Light</strong></td><td><strong>Power usage</strong></td></tr>
</thead>
<tbody>
<tr>
<td>Off</td><td>Off</td><td>0 W</td></tr>
<tr>
<td>1</td><td>Off</td><td>11 W</td></tr>
<tr>
<td>2</td><td>Off</td><td>30 W</td></tr>
<tr>
<td>3</td><td>Off</td><td>70 W</td></tr>
<tr>
<td>Off</td><td>On</td><td>21 W</td></tr>
<tr>
<td>1</td><td>On</td><td>32 W</td></tr>
<tr>
<td>2</td><td>On</td><td>50 W</td></tr>
<tr>
<td>3</td><td>On</td><td>90 W</td></tr>
</tbody>
</table>
</div><p>Fortunately, my fan does not have any overlapping power usage between the possible states. However, the fan supports dimming the light, which will ruin our ability to make sense of anything purely from wattage. I have never bothered to use the light in a dimmed state though, so this does not matter to me, but YMMV.</p>
<p>All we need now is to put some logic into Home Assistant to set the fan and light states using the wattage reading from the power meter. Here are the additional config to add to the <code>configuration.yaml</code>:</p>
<h3 id="heading-fan-entity">Fan entity</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">fan:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">fans:</span>
      <span class="hljs-attr">office_fan:</span>

        <span class="hljs-string">...</span> <span class="hljs-string">the</span> <span class="hljs-string">config</span> <span class="hljs-string">we</span> <span class="hljs-string">added</span> <span class="hljs-string">earlier</span> <span class="hljs-string">...</span>

        <span class="hljs-attr">availability_template:</span> <span class="hljs-string">&gt;-
          {% set s = states('sensor.shellypm_282c_power') %}
          {{ s not in ['unavailable', 'unknown', 'none'] }}
</span>
        <span class="hljs-attr">value_template:</span> <span class="hljs-string">&gt;
          {% set p = (states('input_number.stable_shellypm_282c_power') | float) %}
          {{ (p &gt; 0 and (p &lt; 20 or p &gt; 22)) }}
</span>
        <span class="hljs-attr">percentage_template:</span> <span class="hljs-string">&gt;
          {% set p = states('input_number.stable_shellypm_282c_power') | float %}
          {% if (p &gt; 9 and p &lt; 13) or (p &gt; 31 and p &lt; 34) %}
            33
          {% elif (p &gt; 28 and p &lt;= 31) or (p &gt; 48 and p &lt; 52) %}
            66
          {% elif (p &gt; 68 and p &lt; 72) or (p &gt; 88 and p &lt; 100) %}
            100
          {% elif p &lt;= 9 %}
            0
          {% else %}
            100
          {% endif %}</span>
</code></pre>
<p>Regardless of the currently-set percentage, we will detect fan levels 1-3 and map them to specific percentages. In this way, the reported percentage will eventually settle at 0, 33, 66, or 100%.</p>
<p>You can see that I added +/- 2W (where possible) to account for variance in the measured usage, and that each case is handling the expected wattage range with the light being either off or on.</p>
<p><code>availability_template</code> is there to notice when the power meter is offline. Since the power meter is wired in-line with the fan, it is safe to assume that if the power meter is offline, so is the fan.</p>
<h3 id="heading-light-entity">Light entity</h3>
<p>The fan has a light, so let’s also create a light entity as well.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">light:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">lights:</span>
      <span class="hljs-attr">office_fan_light:</span>
        <span class="hljs-attr">unique_id:</span> <span class="hljs-string">621b19dc-2927-4978-a322-f18753139cf2</span>
        <span class="hljs-attr">friendly_name:</span> <span class="hljs-string">"Office Fan Light"</span>
        <span class="hljs-attr">icon_template:</span> <span class="hljs-string">mdi:ceiling-fan-light</span>
        <span class="hljs-attr">availability_template:</span> <span class="hljs-string">&gt;-
          {% set s = states('sensor.shellypm_282c_power') %}
          {{ s not in ['unavailable', 'unknown', 'none'] }}
</span>        <span class="hljs-attr">turn_on:</span>
          <span class="hljs-attr">action:</span> <span class="hljs-string">button.press</span>
          <span class="hljs-attr">target:</span>
            <span class="hljs-attr">entity_id:</span> <span class="hljs-string">button.office_light</span>
        <span class="hljs-attr">turn_off:</span>
          <span class="hljs-attr">action:</span> <span class="hljs-string">button.press</span>
          <span class="hljs-attr">target:</span>
            <span class="hljs-attr">entity_id:</span> <span class="hljs-string">button.office_light</span>
        <span class="hljs-attr">value_template:</span> <span class="hljs-string">&gt;
          {% set p = (states('input_number.stable_shellypm_282c_power') | float) %}
          {{ (p &gt; 20 and p &lt; 22) or
             (p &gt; 31 and p &lt; 34) or
             (p &gt; 48 and p &lt; 52) or
             (p &gt; 88 and p &lt; 100) }}</span>
</code></pre>
<h3 id="heading-ignoring-intermediate-readings">Ignoring intermediate readings</h3>
<p>You’ll notice that the configurations are reading the wattage value from <code>input_number.stable_shellypm_282c_power</code>, note the <strong>stable</strong> in the name.</p>
<p>This is because of a quirk that creates finicky behavior where the power meter will report intermediary wattages before settling into the new wattage. For example, if I turn the fan from level 1 (11W) to level 2 (30W), the meter will momentarily read something like 25W before settling into 30W. The intermediate readings should be thrown out, which is the role of this automation I put into <code>automations.yaml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">"1752985898000"</span>
  <span class="hljs-attr">alias:</span> <span class="hljs-string">"Check office fan wattage"</span>
  <span class="hljs-attr">trigger:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">state</span>
      <span class="hljs-attr">entity_id:</span> <span class="hljs-string">sensor.shellypm_282c_power</span>
      <span class="hljs-attr">for:</span>
        <span class="hljs-attr">seconds:</span> <span class="hljs-number">2</span>
  <span class="hljs-attr">action:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">service:</span> <span class="hljs-string">input_number.set_value</span>
      <span class="hljs-attr">target:</span>
        <span class="hljs-attr">entity_id:</span> <span class="hljs-string">input_number.stable_shellypm_282c_power</span>
      <span class="hljs-attr">data:</span>
        <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ states('sensor.shellypm_282c_power')|float }}</span>"</span>
</code></pre>
<p>It takes the raw readings from the power meter, and requires that the reading stay the same for 2 seconds before saving that value to <code>input_number.stable_shellypm_282c_power</code>. That’s a variable I declare in <code>configuration.yaml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">input_number:</span>
  <span class="hljs-attr">stable_shellypm_282c_power:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Established</span> <span class="hljs-string">stable</span> <span class="hljs-string">power</span> <span class="hljs-string">reading</span> <span class="hljs-string">from</span> <span class="hljs-string">the</span> <span class="hljs-string">Shelly</span> <span class="hljs-string">PM</span> <span class="hljs-string">282C</span>
    <span class="hljs-attr">min:</span> <span class="hljs-number">0</span>
    <span class="hljs-attr">max:</span> <span class="hljs-number">2000</span>
    <span class="hljs-attr">step:</span> <span class="hljs-number">0.1</span>
    <span class="hljs-attr">initial:</span> <span class="hljs-number">0</span>
</code></pre>
<p>And with all that, you will not see finicky behavior where the wattage reading momentarily falls outside of the <code>if</code> condition ranges.</p>
<h3 id="heading-enjoy-your-controllable-fan">Enjoy your controllable fan!</h3>
<p>In Home Assistant:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753935371130/0dde25d7-4656-444f-b5a1-8562fa0c8bcd.png" alt class="image--center mx-auto" /></p>
<p>… and in Google Home!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753935465100/a2347c23-c0ea-43d2-986a-ad2fb6aa3b41.png" alt class="image--center mx-auto" /></p>
<p>And it’ll still update to the real state of the fan even if you use the original remote, or turn off the switch on the wall!</p>
]]></content:encoded></item><item><title><![CDATA[Accurate Thermal Control with Basic Space Heaters]]></title><description><![CDATA[There’s a problem with temperature-controlled space heaters — they’re all terrible at temperature control.
Hear me out
It’s physically impossible for any (self-contained) space heater to maintain a target temperature with any respectable precision. A...]]></description><link>https://victorchang.codes/accurate-thermal-control-with-basic-space-heaters</link><guid isPermaLink="true">https://victorchang.codes/accurate-thermal-control-with-basic-space-heaters</guid><category><![CDATA[space heater]]></category><category><![CDATA[temperature]]></category><category><![CDATA[thermostat]]></category><category><![CDATA[Temperature Sensor]]></category><category><![CDATA[heater]]></category><category><![CDATA[Room heater]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Sun, 16 Mar 2025 21:12:12 GMT</pubDate><content:encoded><![CDATA[<p>There’s a problem with temperature-controlled space heaters — they’re all terrible at temperature control.</p>
<h1 id="heading-hear-me-out">Hear me out</h1>
<p>It’s physically impossible for any (self-contained) space heater to maintain a target temperature with any respectable precision. And I do mean <strong>maintain</strong>. As in, if I want 70° F, I don’t want 69° or 71° F. In practice, my house thermostat is able to maintain the room temperature between 69.5 and 70.0° F. Space heaters cannot dream of achieving this precise range and I observe them varying by more than 10 degrees.</p>
<p>Why is that? Simple. <strong>The thermometer is inside the space heater.</strong> That means the thermometer is measuring a temperature that’s higher than the room temperature due to being influenced by the heater itself.</p>
<p>Designers can apply some tricks to alleviate this issue, such as placing the thermometer as far from the heating element as possible, but space heaters are only so big and the thermometer can only be so far. Real thermostats sit on walls, tens of feet away from any vents/registers, for a reason.</p>
<p>Since we know that the thermometer would be reading an elevated temperature when the heater is on, let’s logic out what would happen if we actually tried to maintain 70° F. Let’s be naive and make the heater turn off once the thermometer reads 70.5° (a reasonable cut-off point you can have with a wall thermostat). The heater heats itself up and almost immediately reaches 70.5° F. Now the heater turns off while the room temperature barely budged.</p>
<p>Obviously not a functional approach, so let’s be less naive: Give the heater some wiggle room. Wait until the temperature reading is <strong>far</strong> higher than the target temperature before turning off. Can there be any precision with this approach? No, and that is just an unavoidable fact of physical reality.</p>
<h1 id="heading-what-can-be-done">What can be done?</h1>
<p>The thermometer has to be detached from the space heater. Perhaps there are high end models with remote sensors, but I have not found them and they likely cost more than it’s worth. Instead, I’ve set out to solve this with my own thermometer, a smart plug, and Home Assistant automations.</p>
<h2 id="heading-thermometer">Thermometer</h2>
<p>Get anything that can integrate with Home Assistant. Ideally with frequent reports and precise readings. I have seen some that only report at 0.5° F increments and that’s simply too wide for my tastes, but you can always make do with accepting a slightly looser control of the room’s temperature range.</p>
<p>I personally use the <a target="_blank" href="https://sensirion.com/products/catalog/SHT40">SHT40</a>-series thermometer and ESP8266/ESP32 microcontrollers running ESPHome to have good precision and frequent readings.</p>
<p>Specifically, in ESPHome I have the temperature polled every second and the temperature is averaged out like so:</p>
<pre><code class="lang-yaml">    <span class="hljs-attr">sliding_window_moving_average:</span>
        <span class="hljs-attr">window_size:</span> <span class="hljs-number">60</span>
        <span class="hljs-attr">send_every:</span> <span class="hljs-number">30</span>
</code></pre>
<p>This applies a moving average of the last minute’s worth of readings, and reports the temperature to Home Assistant every 30 seconds.</p>
<p>Of course, you should put the sensor a good distance away from the space heater.</p>
<h2 id="heading-smart-plug">Smart Plug</h2>
<p>Optimistically, you can use anything that integrates with Home Assistant. But there are two things to consider:</p>
<h3 id="heading-dont-burn-your-house-down">Don’t burn your house down</h3>
<p>Space heaters can use basically the entire amperage of the circuit and can lead to melting/burning cheap smart plugs that exaggerate the amperage they support. Do your research and find a trustworthy brand/model. Did you know that your usual wall outlet is rated for 15A but your usual cheap smart plugs (and extension cables, for that matter) are only rated 13A? And that’s still just their marketed claim — they could have secretly cheapened out even further. Crazy…</p>
<p>But in any case, monitor the temperature of the smart plug at the beginning until you’re sure it handles the load. I’ve also gone a step farther and only use the medium power (900W or 8A) setting on the space heater.</p>
<h3 id="heading-what-happens-if-the-network-or-home-assistant-goes-down">What happens if the network or Home Assistant goes down?</h3>
<p>If the smart plug was on and Home Assistant can no longer instruct it to turn off, will the heater turn the room into a sauna? How can we implement safety cut-offs?</p>
<p>You can use the heater itself where you heat the room to the max temperature you’d want it to reach and then slowly lower the target temperature until it switches off, but that depends on a trust that no person or pet would ever accidentally change that setting.</p>
<p>Maybe some smart plugs have timers where it can automatically turn off after a set time, but is that on-device or cloud-dependent?</p>
<p>I went with the timer option, and to make sure the timeout runs on-device, I opted to use <a target="_blank" href="https://roborooter.com/post/flashing-sonoff-s31-with-esphome/">ESPHome on a Sonoff S31</a>. This enables me to put a 1 hour timeout straight into the smart plug’s firmware so it is for sure running on-device. At the same time, I make sure that it doesn’t turn back on after a power outage. The YAML config is pretty simple:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">switch:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">gpio</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">"${name} Relay"</span>
    <span class="hljs-attr">pin:</span> <span class="hljs-string">GPIO12</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">relay</span>
    <span class="hljs-comment"># Stay off after a power outage.</span>
    <span class="hljs-attr">restore_mode:</span> <span class="hljs-string">RESTORE_DEFAULT_OFF</span>
    <span class="hljs-comment"># For use with the space heater:</span>
    <span class="hljs-attr">on_turn_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">script.stop:</span> <span class="hljs-string">turn_off_after_one_hour</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">script.execute:</span> <span class="hljs-string">turn_off_after_one_hour</span>
    <span class="hljs-attr">on_turn_off:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">script.stop:</span> <span class="hljs-string">turn_off_after_one_hour</span>

<span class="hljs-comment"># For use with the space heater:</span>
<span class="hljs-attr">script:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">turn_off_after_one_hour</span>
    <span class="hljs-attr">then:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">60min</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">switch.turn_off:</span> <span class="hljs-string">relay</span>
</code></pre>
<h2 id="heading-automations-in-home-assistant">Automations in Home Assistant</h2>
<p>Let’s set up automations to use a space heater to keep a room between 71° and 71.5° F.</p>
<p>There is a gotcha I’ve learned the hard way and I’ll explain it now and help you avoid it. First, consider this setup:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">alias:</span> <span class="hljs-string">Corner</span> <span class="hljs-string">Bedroom</span> <span class="hljs-string">Heater</span> <span class="hljs-bullet">-</span> <span class="hljs-string">Turn</span> <span class="hljs-string">off</span> <span class="hljs-string">if</span> <span class="hljs-string">above</span> <span class="hljs-number">71.</span><span class="hljs-string">5F</span>
<span class="hljs-attr">description:</span> <span class="hljs-string">""</span>
<span class="hljs-attr">triggers:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">entity_id:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">sensor.corner_bedroom_temperature</span>
    <span class="hljs-attr">above:</span> <span class="hljs-number">71.5</span>
    <span class="hljs-attr">trigger:</span> <span class="hljs-string">numeric_state</span>
<span class="hljs-attr">actions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">type:</span> <span class="hljs-string">turn_off</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">device</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">entity</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">domain:</span> <span class="hljs-string">switch</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">single</span>
</code></pre>
<p>This is how one would naively configure to have the smart plug turn off if the temperature is above 71.5° F. But its major flaw is that Home Assistant has to observe the <em>moment</em> when the number goes from &lt;=71.5 to &gt;71.5. If there is any sort of lapse in communication, or Home Assistant was updating/restarting, it is possible for it to miss the moment that the value changed to &gt;71.5. When that happens, this automation will not trigger and it <strong>will not instruct the smart plug to turn off</strong>.</p>
<p>Instead, you have to poll the value. The best you can do with the available automation syntax is a poll every minute, so let’s do that where we poll on the 0th second of every minute:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">alias:</span> <span class="hljs-string">Corner</span> <span class="hljs-string">Bedroom</span> <span class="hljs-string">Heater</span> <span class="hljs-bullet">-</span> <span class="hljs-string">Turn</span> <span class="hljs-string">off</span> <span class="hljs-string">if</span> <span class="hljs-string">above</span> <span class="hljs-number">71.</span><span class="hljs-string">5F</span>
<span class="hljs-attr">description:</span> <span class="hljs-string">""</span>
<span class="hljs-attr">triggers:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">seconds:</span> <span class="hljs-string">"0"</span>
    <span class="hljs-attr">trigger:</span> <span class="hljs-string">time_pattern</span>
<span class="hljs-attr">conditions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">condition:</span> <span class="hljs-string">numeric_state</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">sensor.corner_bedroom_temperature</span>
    <span class="hljs-attr">above:</span> <span class="hljs-number">71.5</span>
<span class="hljs-attr">actions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">type:</span> <span class="hljs-string">turn_off</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">device</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">entity</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">domain:</span> <span class="hljs-string">switch</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">single</span>
</code></pre>
<p>This way, things that could cause the previous setup to fail would be rectified within a minute. I suppose you could create a duplicate automation entry that triggers on the 30th second of every minute to bring the latency down to 30 seconds too. Up to you.</p>
<p>Here would be the corresponding “turn on” automation:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">alias:</span> <span class="hljs-string">Corner</span> <span class="hljs-string">Bedroom</span> <span class="hljs-string">Heater</span> <span class="hljs-bullet">-</span> <span class="hljs-string">Turn</span> <span class="hljs-string">on</span> <span class="hljs-string">if</span> <span class="hljs-string">below</span> <span class="hljs-string">71F</span>
<span class="hljs-attr">description:</span> <span class="hljs-string">""</span>
<span class="hljs-attr">triggers:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">seconds:</span> <span class="hljs-string">"0"</span>
    <span class="hljs-attr">trigger:</span> <span class="hljs-string">time_pattern</span>
<span class="hljs-attr">conditions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">condition:</span> <span class="hljs-string">numeric_state</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">sensor.corner_bedroom_temperature</span>
    <span class="hljs-attr">below:</span> <span class="hljs-number">71</span>
<span class="hljs-attr">actions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">type:</span> <span class="hljs-string">turn_on</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">device</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">entity</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">domain:</span> <span class="hljs-string">switch</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">single</span>
</code></pre>
<p>And that’s the bare minimum automation setup. You can of course add any other conditions you’d like, such as only triggering during the evening:</p>
<pre><code class="lang-yaml">  <span class="hljs-bullet">-</span> <span class="hljs-attr">condition:</span> <span class="hljs-string">time</span>
    <span class="hljs-attr">after:</span> <span class="hljs-string">"17:00:00"</span>
    <span class="hljs-attr">before:</span> <span class="hljs-string">"22:00:00"</span>
</code></pre>
<p>And in that case, you probably want a final shut-off automation or else the smart plug might run the heater for up to an hour longer.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">alias:</span> <span class="hljs-string">Corner</span> <span class="hljs-string">Bedroom</span> <span class="hljs-string">Heater</span> <span class="hljs-bullet">-</span> <span class="hljs-string">Turn</span> <span class="hljs-string">off</span> <span class="hljs-string">at</span> <span class="hljs-string">the</span> <span class="hljs-string">end</span>
<span class="hljs-attr">description:</span> <span class="hljs-string">""</span>
<span class="hljs-attr">mode:</span> <span class="hljs-string">single</span>
<span class="hljs-attr">triggers:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">at:</span> <span class="hljs-string">"22:01:00"</span>
    <span class="hljs-attr">trigger:</span> <span class="hljs-string">time</span>
<span class="hljs-attr">conditions:</span> []
<span class="hljs-attr">actions:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">type:</span> <span class="hljs-string">turn_off</span>
    <span class="hljs-attr">device_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">device</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">entity_id:</span> <span class="hljs-string">&lt;your</span> <span class="hljs-string">entity</span> <span class="hljs-string">id&gt;</span>
    <span class="hljs-attr">domain:</span> <span class="hljs-string">switch</span>
</code></pre>
<p>That’s all there is to it! Guess which room used a space heater?</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742158678598/7ef2de8e-6f1a-4e98-a668-fceadc25c9dc.png" alt class="image--center mx-auto" /></p>
]]></content:encoded></item><item><title><![CDATA[Controlling Ceiling Fans with Home Assistant]]></title><description><![CDATA[My ceiling fans are not smart, but let's try to control them using Home Assistant to make them smart! The key is that they have remote controls, so it should be possible to make something that can act like those remotes.
Signal Gathering
These are my...]]></description><link>https://victorchang.codes/controlling-ceiling-fans-with-home-assistant</link><guid isPermaLink="true">https://victorchang.codes/controlling-ceiling-fans-with-home-assistant</guid><category><![CDATA[cc1101]]></category><category><![CDATA[Home Assistant]]></category><category><![CDATA[esphome]]></category><category><![CDATA[radio]]></category><category><![CDATA[Radio Frequency]]></category><category><![CDATA[ESP32]]></category><category><![CDATA[Ceiling Fan ]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Mon, 13 May 2024 04:28:46 GMT</pubDate><content:encoded><![CDATA[<p>My ceiling fans are not smart, but let's try to control them using Home Assistant to make them smart! The key is that they have remote controls, so it should be possible to make something that can act like those remotes.</p>
<h2 id="heading-signal-gathering">Signal Gathering</h2>
<p>These are my remotes:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715414763725/4fd11fc8-672d-479a-936c-c5e312f20bd2.png" alt class="image--center mx-auto" /></p>
<p>Both of these are radio-based and use 434 and 350MHz signals respectively. Let's first see what kind of signals they emit and will have to hope it's the same signal every time (no rolling codes). Since I have a Flipper Zero, I'll use that:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715414844113/f50dc96c-2a3c-45d7-a4e7-f05320241f07.png" alt class="image--center mx-auto" /></p>
<p>From the look of the signal timeline graph, it did detect that I had pressed a button on the remote. And when I press <code>Send</code> to try broadcasting it again, it works! Rinse and repeat for every other button on the remote and we now have a recording of every signal we can know of. Now to look into how we can make a permanent device to broadcast these signals via Home Assistant controls.</p>
<h2 id="heading-software">Software</h2>
<p>On the Home Assistant side, I'll still need some sort of radio device to broadcast these signals, so let's see what <a target="_blank" href="https://esphome.io/components/remote_transmitter.html">ESPHome would let us do</a>. Looks like <code>remote_transmitter</code> is what I want, but it's strange... I don't think it's the right solution for a couple reasons.</p>
<ol>
<li><p>The docs specifically say it is for 433MHz with no configurability, but I also need to broadcast 350MHz.</p>
</li>
<li><p>It appears to just use a GPIO pin directly as the transmitter. As neat as that is, further reading reveals that this will create interference at many other frequencies unless we use a <a target="_blank" href="https://en.wikipedia.org/wiki/Low-pass_filter">low-pass filter</a>.</p>
</li>
</ol>
<p>Is there an alternative? Further searching reveals a popular radio chip, the <a target="_blank" href="https://www.ti.com/product/CC1101">TI CC1101</a>. It's actually the same radio used in the Flipper Zero! With this, we can do standard SPI communication with the CC1101 and let it deal with the details of actually transmitting my commands. With <a target="_blank" href="https://github.com/dbuezas/esphome-cc1101">dbuezas' CC1101 driver</a> for ESPHome, the software side of things is covered.</p>
<h2 id="heading-hardware">Hardware</h2>
<p>A CC1101 board with antenna can be had for about <a target="_blank" href="https://www.aliexpress.us/item/3256802417341436.html">$6 with shipping</a>. I picked the "CC1101 433MHZ SMA" variant to be specific. For the microcontroller, I went with an ESP32 D1 Mini which is also about <a target="_blank" href="https://www.aliexpress.us/item/3256805786312797.html">$6 with shipping</a>, specifically I picked a microUSB model.</p>
<p>Next is to make a case for it. The D1 Mini is likely pretty standardized in its dimensions, but there are a lot of variations of the CC1101 boards. I <a target="_blank" href="https://drive.google.com/drive/folders/1cuJ0wx08fgKSGh9SPC4yhPU6vTLEEuXA">modeled something</a> for my particular boards, so YMMV.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715451496228/cdd77121-2060-415b-95ba-f7d281ce13c5.png" alt class="image--center mx-auto" /></p>
<p>Some soldering later...</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715453184815/c879f530-f2da-4658-8b9f-4b7476cd1c1f.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715453243923/e3c1ca89-f573-4203-8132-4aeed814d60b.png" alt class="image--center mx-auto" /></p>
<p>The two shells can be rubber banded together, or you can print as many of the "Case Wrap" model as you want.</p>
<h2 id="heading-processing-the-commands">Processing the commands</h2>
<p>I used my Flipper Zero to read the commands sent by each button on my remotes, but it's not a requirement. <a target="_blank" href="https://github.com/dbuezas/esphome-cc1101">Dbuezas' CC1101 driver</a> includes code to log the signals that it detects straight into ESPHome's log output.</p>
<p>But this is just a raw recording which implies some issues:</p>
<ol>
<li><p>There can be noise before and after the desired signal. If not trimmed out, the noise in the beginning will still be broadcasted each time you want to send the command - adding latency.</p>
</li>
<li><p>The remotes like to send the command multiple times in quick succession, but this is often unnecessary and adds significantly more characters into the YAML. I reduce it down to only sending the command once, and if you would prefer to continue sending it a few times then I think it's best to repeat the signal in code rather than with a longer recorded signal.</p>
</li>
<li><p>It's hard to see just from the raw numbers what constitutes as the "command".</p>
</li>
</ol>
<p>To help us identify the specific command from the raw reading, dbueza created a corresponding <a target="_blank" href="https://github.com/dbuezas/esphome-remote_receiver-oscilloscope">tool to visualize the signal</a>, which is incredibly helpful! And the only incompatibility it had with my Flipper recordings was it expected comma delimiters instead of spaces. No big deal, find and replace spaces with commas and you can input the Flipper's readings into the visualizer:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715533527607/63298c94-1715-4c44-864c-7c7c797015e8.png" alt class="image--center mx-auto" /></p>
<p>It seems to send the signal a couple times, but in reality you can just take a single segment like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715533391627/eb7d80ef-c06e-4f1d-a6ca-362c77924922.png" alt class="image--center mx-auto" /></p>
<p>Hovering the cursor to the left of the left-most rectangle, you can see which number it underlines &amp; highlights in the CSV. In this case, a particular <code>453</code>. Remember where this specific <code>453</code> is.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715533696559/9266cd84-091d-4ccb-811c-450174236673.png" alt class="image--center mx-auto" /></p>
<p>Do the same for the last rectangle, where it ends with <code>411</code>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715533725085/d310eb4a-2744-4940-90d3-27e9dc450442.png" alt class="image--center mx-auto" /></p>
<p>We will just need to select the entire string between the <code>453</code> and the <code>411</code> and put that code into the <code>code: []</code> line as seen in the <a target="_blank" href="https://github.com/dbuezas/esphome-cc1101/blob/e85352de73ac479063ef3aebf240ba2d06f6fd57/cc1101.yaml#L61">sample YAML file</a>. It should look like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">button:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Light</span>
    <span class="hljs-attr">on_press:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">get_cc1101(transceiver).setFreq(433.92);</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">get_cc1101(transceiver).beginTransmission();</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">remote_transmitter.transmit_raw:</span>
          <span class="hljs-attr">code:</span> [<span class="hljs-number">453</span>,<span class="hljs-number">-344</span>,<span class="hljs-number">459</span>,<span class="hljs-number">-352</span>,<span class="hljs-number">403</span>,<span class="hljs-number">-414</span>,<span class="hljs-number">371</span>,<span class="hljs-number">-422</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-390</span>,<span class="hljs-number">375</span>,<span class="hljs-number">-424</span>,<span class="hljs-number">375</span>,<span class="hljs-number">-424</span>,<span class="hljs-number">407</span>,<span class="hljs-number">-388</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-388</span>,<span class="hljs-number">377</span>,<span class="hljs-number">-422</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-392</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-5172</span>,<span class="hljs-number">803</span>,<span class="hljs-number">-414</span>,<span class="hljs-number">435</span>,<span class="hljs-number">-746</span>,<span class="hljs-number">413</span>,<span class="hljs-number">-800</span>,<span class="hljs-number">837</span>,<span class="hljs-number">-356</span>,<span class="hljs-number">821</span>,<span class="hljs-number">-380</span>,<span class="hljs-number">429</span>,<span class="hljs-number">-774</span>,<span class="hljs-number">825</span>,<span class="hljs-number">-380</span>,<span class="hljs-number">837</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">435</span>,<span class="hljs-number">-744</span>,<span class="hljs-number">443</span>,<span class="hljs-number">-772</span>,<span class="hljs-number">447</span>,<span class="hljs-number">-750</span>,<span class="hljs-number">815</span>,<span class="hljs-number">-398</span>,<span class="hljs-number">413</span>,<span class="hljs-number">-756</span>,<span class="hljs-number">843</span>,<span class="hljs-number">-368</span>,<span class="hljs-number">451</span>,<span class="hljs-number">-736</span>,<span class="hljs-number">847</span>,<span class="hljs-number">-368</span>,<span class="hljs-number">821</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">421</span>,<span class="hljs-number">-784</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-778</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-770</span>,<span class="hljs-number">443</span>,<span class="hljs-number">-776</span>,<span class="hljs-number">429</span>,<span class="hljs-number">-784</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-752</span>,<span class="hljs-number">457</span>,<span class="hljs-number">-746</span>,<span class="hljs-number">837</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">841</span>,<span class="hljs-number">-384</span>,<span class="hljs-number">821</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">841</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-776</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-776</span>,<span class="hljs-number">811</span>,<span class="hljs-number">-358</span>,<span class="hljs-number">445</span>,<span class="hljs-number">-754</span>,<span class="hljs-number">817</span>,<span class="hljs-number">-400</span>,<span class="hljs-number">841</span>,<span class="hljs-number">-342</span>,<span class="hljs-number">425</span>,<span class="hljs-number">-786</span>,<span class="hljs-number">449</span>,<span class="hljs-number">-754</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-770</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-774</span>,<span class="hljs-number">431</span>,<span class="hljs-number">-778</span>,<span class="hljs-number">391</span>,<span class="hljs-number">-808</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-778</span>,<span class="hljs-number">419</span>,<span class="hljs-number">-772</span>,<span class="hljs-number">445</span>,<span class="hljs-number">-754</span>,<span class="hljs-number">449</span>,<span class="hljs-number">-756</span>,<span class="hljs-number">431</span>,<span class="hljs-number">-784</span>,<span class="hljs-number">389</span>,<span class="hljs-number">-806</span>,<span class="hljs-number">409</span>,<span class="hljs-number">-776</span>,<span class="hljs-number">819</span>,<span class="hljs-number">-386</span>,<span class="hljs-number">447</span>,<span class="hljs-number">-752</span>,<span class="hljs-number">413</span>,<span class="hljs-number">-768</span>,<span class="hljs-number">451</span>,<span class="hljs-number">-760</span>,<span class="hljs-number">837</span>,<span class="hljs-number">-366</span>,<span class="hljs-number">833</span>,<span class="hljs-number">-362</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">831</span>,<span class="hljs-number">-326</span>,<span class="hljs-number">847</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">819</span>,<span class="hljs-number">-364</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">819</span>,<span class="hljs-number">-364</span>,<span class="hljs-number">443</span>,<span class="hljs-number">-770</span>,<span class="hljs-number">805</span>,<span class="hljs-number">-386</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-352</span>,<span class="hljs-number">817</span>,<span class="hljs-number">-390</span>,<span class="hljs-number">445</span>,<span class="hljs-number">-748</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-800</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-1000000</span>]
      <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">get_cc1101(transceiver).endTransmission();</span>
</code></pre>
<p>Note, I made two changes compared to the sample:</p>
<ol>
<li><p>The <code>lambda: get_cc1101(transceiver).setFreq(433.92);</code> line is added because my two ceiling fans use different frequencies. I simply always set the relevant frequency prior to each transmission.</p>
</li>
<li><p>I added a <code>-1000000</code> to the end of the <code>code</code> list to ensure it waits for 1 second before moving on to any further broadcasts. This is because there's a cooldown period where the fan ignores follow-up commands if they happen too soon after the first command.</p>
</li>
</ol>
<p>And that's all! Do the same process for each button on the remote(s) and those buttons can now appear in Home Assistant like so:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715485785473/e4ec59b1-4b51-4c97-b2f3-466af88d9624.png" alt class="image--center mx-auto" /></p>
<p>And like everything else in Home Assistant, it is ripe for automations! 🤩</p>
<h2 id="heading-bonus-round-light-dimming">Bonus Round - Light Dimming</h2>
<p>My office light has a light dimming function where you hold on the light button (on the remote) and the light will progressively dim and brighten. You then let go of the button when it reaches the brightness you want. The signal it sends for this is different from the usual light on/off command and has to be handled separately. The problem is finding a clean way to make ESPHome continuously send the command until an arbitrary moment.</p>
<p>My first approach was to make a button that would send the command for an entire second, then I would keep clicking on it approximately every second until it reached the desired brightness. However, it depends on my own timing for each sequential click because each command is queued up to run right after the previous command. Clicking too quickly actually causes it to send for longer than intended as the queue has to work through a backlog of commands.</p>
<p>It's a terrible UX. Can we do better?</p>
<h3 id="heading-what-about-a-toggle-switch">What about a toggle switch?</h3>
<p>It should be possible to use a <a target="_blank" href="https://esphome.io/components/switch/template.html">toggle switch</a> that sets a corresponding boolean variable, then have a loop that continuously sends the dimming command while the variable is <code>true</code>.</p>
<p>The problem is this doesn't work:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">switch:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Light</span> <span class="hljs-string">Dimming</span> <span class="hljs-string">Hold</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_dimming</span>
    <span class="hljs-attr">turn_on_action:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">script.execute:</span> <span class="hljs-string">office_dimmer_script</span>
</code></pre>
<p>The switch always immediately turned back off. I couldn't figure out why it was behaving differently from all the other toggle switches I had on other devices. Turns out there is what I'd consider to be a bug with the <code>platform: template</code> switches. Michele184 on the Home Assistant forum posted a workaround <a target="_blank" href="https://community.home-assistant.io/t/esphome-switch-template-unexpected-off/333070/6">here</a>.</p>
<p>So the working configuration is:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">switch:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">template</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Office</span> <span class="hljs-string">Light</span> <span class="hljs-string">Dimming</span> <span class="hljs-string">Hold</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">office_dimming</span>
    <span class="hljs-attr">turn_on_action:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">switch.template.publish:</span> 
          <span class="hljs-attr">id:</span> <span class="hljs-string">office_dimming</span>
          <span class="hljs-attr">state:</span> <span class="hljs-string">ON</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">script.execute:</span> <span class="hljs-string">office_dimmer_script</span>
    <span class="hljs-attr">turn_off_action:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">switch.template.publish:</span> 
          <span class="hljs-attr">id:</span> <span class="hljs-string">office_dimming</span>
          <span class="hljs-attr">state:</span> <span class="hljs-string">OFF</span>

<span class="hljs-attr">script:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">office_dimmer_script</span>
    <span class="hljs-attr">then:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">while:</span>
          <span class="hljs-attr">condition:</span>
            <span class="hljs-attr">switch.is_on:</span> <span class="hljs-string">office_dimming</span>
          <span class="hljs-attr">then:</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
                ESP_LOGD("Office Light", "Dim triggered");
                get_cc1101(transceiver).setFreq(433.92);
                get_cc1101(transceiver).beginTransmission();
</span>            <span class="hljs-bullet">-</span> <span class="hljs-attr">remote_transmitter.transmit_raw:</span>
                <span class="hljs-attr">code:</span> [<span class="hljs-number">449</span>,<span class="hljs-number">-386</span>,<span class="hljs-number">391</span>,<span class="hljs-number">-376</span>,<span class="hljs-number">423</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">391</span>,<span class="hljs-number">-414</span>,<span class="hljs-number">417</span>,<span class="hljs-number">-380</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-360</span>,<span class="hljs-number">417</span>,<span class="hljs-number">-392</span>,<span class="hljs-number">427</span>,<span class="hljs-number">-386</span>,<span class="hljs-number">433</span>,<span class="hljs-number">-352</span>,<span class="hljs-number">405</span>,<span class="hljs-number">-388</span>,<span class="hljs-number">439</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-5178</span>,<span class="hljs-number">819</span>,<span class="hljs-number">-384</span>,<span class="hljs-number">421</span>,<span class="hljs-number">-752</span>,<span class="hljs-number">451</span>,<span class="hljs-number">-780</span>,<span class="hljs-number">811</span>,<span class="hljs-number">-366</span>,<span class="hljs-number">853</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">421</span>,<span class="hljs-number">-782</span>,<span class="hljs-number">835</span>,<span class="hljs-number">-342</span>,<span class="hljs-number">841</span>,<span class="hljs-number">-362</span>,<span class="hljs-number">445</span>,<span class="hljs-number">-756</span>,<span class="hljs-number">447</span>,<span class="hljs-number">-768</span>,<span class="hljs-number">439</span>,<span class="hljs-number">-748</span>,<span class="hljs-number">837</span>,<span class="hljs-number">-380</span>,<span class="hljs-number">431</span>,<span class="hljs-number">-740</span>,<span class="hljs-number">825</span>,<span class="hljs-number">-410</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-774</span>,<span class="hljs-number">813</span>,<span class="hljs-number">-372</span>,<span class="hljs-number">857</span>,<span class="hljs-number">-348</span>,<span class="hljs-number">421</span>,<span class="hljs-number">-788</span>,<span class="hljs-number">413</span>,<span class="hljs-number">-780</span>,<span class="hljs-number">443</span>,<span class="hljs-number">-734</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-774</span>,<span class="hljs-number">433</span>,<span class="hljs-number">-780</span>,<span class="hljs-number">391</span>,<span class="hljs-number">-776</span>,<span class="hljs-number">475</span>,<span class="hljs-number">-740</span>,<span class="hljs-number">835</span>,<span class="hljs-number">-358</span>,<span class="hljs-number">851</span>,<span class="hljs-number">-344</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-368</span>,<span class="hljs-number">857</span>,<span class="hljs-number">-346</span>,<span class="hljs-number">423</span>,<span class="hljs-number">-768</span>,<span class="hljs-number">437</span>,<span class="hljs-number">-768</span>,<span class="hljs-number">815</span>,<span class="hljs-number">-408</span>,<span class="hljs-number">401</span>,<span class="hljs-number">-770</span>,<span class="hljs-number">849</span>,<span class="hljs-number">-344</span>,<span class="hljs-number">853</span>,<span class="hljs-number">-364</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-736</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-772</span>,<span class="hljs-number">449</span>,<span class="hljs-number">-744</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-804</span>,<span class="hljs-number">449</span>,<span class="hljs-number">-746</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-768</span>,<span class="hljs-number">439</span>,<span class="hljs-number">-778</span>,<span class="hljs-number">431</span>,<span class="hljs-number">-748</span>,<span class="hljs-number">449</span>,<span class="hljs-number">-750</span>,<span class="hljs-number">459</span>,<span class="hljs-number">-744</span>,<span class="hljs-number">835</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">447</span>,<span class="hljs-number">-780</span>,<span class="hljs-number">411</span>,<span class="hljs-number">-786</span>,<span class="hljs-number">417</span>,<span class="hljs-number">-782</span>,<span class="hljs-number">415</span>,<span class="hljs-number">-772</span>,<span class="hljs-number">831</span>,<span class="hljs-number">-382</span>,<span class="hljs-number">423</span>,<span class="hljs-number">-748</span>,<span class="hljs-number">485</span>,<span class="hljs-number">-722</span>,<span class="hljs-number">427</span>,<span class="hljs-number">-790</span>,<span class="hljs-number">823</span>,<span class="hljs-number">-354</span>,<span class="hljs-number">863</span>,<span class="hljs-number">-350</span>,<span class="hljs-number">851</span>,<span class="hljs-number">-348</span>,<span class="hljs-number">441</span>,<span class="hljs-number">-744</span>,<span class="hljs-number">843</span>,<span class="hljs-number">-364</span>,<span class="hljs-number">841</span>,<span class="hljs-number">-386</span>,<span class="hljs-number">815</span>,<span class="hljs-number">-362</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-344</span>,<span class="hljs-number">443</span>,<span class="hljs-number">-770</span>,<span class="hljs-number">839</span>,<span class="hljs-number">-350</span>,<span class="hljs-number">873</span>,<span class="hljs-number">-352</span>,<span class="hljs-number">817</span>,<span class="hljs-number">-350</span>,<span class="hljs-number">445</span>,<span class="hljs-number">-1</span>]
            <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
                get_cc1101(transceiver).endTransmission();
</span>            <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">25ms</span>
</code></pre>
<p>Now I can simulate holding the dimming function with a toggle switch!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1715499688501/1fe3de53-1efa-4995-842f-9d0ed01e598c.png" alt class="image--center mx-auto" /></p>
]]></content:encoded></item><item><title><![CDATA[Reverse Engineering the Emporia Vue Utility Connect]]></title><description><![CDATA[I got the Emporia Vue Utility Connect with the intent of using jrouvier's port to ESPHome, but it turns out my model doesn't communicate the same way as the version jrouvier had. Specifically, upon flashing ESPHome onto the Vue I was greeted with thi...]]></description><link>https://victorchang.codes/reverse-engineering-the-emporia-vue-utility-connect</link><guid isPermaLink="true">https://victorchang.codes/reverse-engineering-the-emporia-vue-utility-connect</guid><category><![CDATA[emporia]]></category><category><![CDATA[emporia vue]]></category><category><![CDATA[utility connect]]></category><category><![CDATA[pge]]></category><category><![CDATA[sdge]]></category><category><![CDATA[mgm111]]></category><category><![CDATA[vue]]></category><category><![CDATA[meter]]></category><category><![CDATA[ESP32]]></category><category><![CDATA[zigbee]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Sun, 10 Dec 2023 19:00:21 GMT</pubDate><content:encoded><![CDATA[<p>I got the <a target="_blank" href="https://shop.emporiaenergy.com/products/utility-connect">Emporia Vue Utility Connect</a> with the intent of using <a target="_blank" href="https://github.com/jrouvier/esphome-emporia-vue-utility">jrouvier's port to ESPHome</a>, but it turns out my model doesn't communicate the same way as the version jrouvier had. Specifically, upon flashing ESPHome onto the Vue I was greeted with this error:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702171734312/314118af-f0de-46ea-9773-33f01afd7214.png" alt class="image--center mx-auto" /></p>
<p>That sucks, but should be solvable right? Let's go on a journey!</p>
<h2 id="heading-what-are-the-elements-we-care-about">What are the elements we care about?</h2>
<p>Let's start with scoping out the actual elements at play here: the ESP32 chip, the MGM111 chip, and the utility power meter.</p>
<p>The ESP32 chip is the main microcontroller in the Vue device and communicates with the MGM111 chip over UART.</p>
<p>The MGM111 chip speaks Zigbee which is how it communicates with the power meter.</p>
<p>When installing the ESPHome firmware, we are only modifying the ESP32 chip and need our firmware to transmit the same UART commands to the MGM111 chip that the stock firmware would send. We would then need to make sense of the responses.</p>
<h2 id="heading-is-the-payload-itself-an-error-message">Is the payload itself an error message?</h2>
<p>The payload was expected to be 152 bytes, but mine is 44 bytes. It's significantly smaller, so maybe it's just an error response? How to try to confirm this?</p>
<p>Let's try to induce an error. As seen in the code, a meter-join request is sent before requesting meter readings. Logically, if we try to request a reading without requesting to join the meter first, that would return an error, right?</p>
<p>Turns out the read request returns a 44-byte payload like before. Dang, so it is an error. It also looks almost identical to the previous payload.</p>
<h2 id="heading-is-the-meter-join-request-even-successful">Is the meter join request even successful?</h2>
<p>Let's try to induce an error at this stage.</p>
<p>We can try preventing the Zigbee communication with the meter from happening, but we also need the ESP32 to still be able to connect to my wifi and show its logs. How to do this... Let's stick it in a metal bottle!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702173437174/5586130e-da89-4f85-86c2-9619c0e04120.jpeg" alt class="image--center mx-auto" /></p>
<p>I can just point the mouth of the bottle near a wifi AP and away from the utility meter. So what did that do?</p>
<p>The meter-read request no longer gets a response, so we've successfully broken the Zigbee communication, but the meter-join request still returns a 1. That is suspicious because 1 happens to be the same value it returns even when there is a Zigbee signal. This has proven to be kind of a dead end. What now?</p>
<h2 id="heading-what-if-we-can-confirm-the-stock-firmware-gets-the-same-responses">What if we can confirm the stock firmware gets the same responses?</h2>
<p>The ESP32 and MGM111 communicate over a UART bridge, so in theory we should be able to sniff the traffic. Let's flash the original firmware back on to see how it behaves.</p>
<p>Fortunately, jrouvier already <a target="_blank" href="https://github.com/jrouvier/esphome-emporia-vue-utility/blob/main/docs/pinout.md#p5">documented the pins</a> of interest on the board, which for us would be the P5 group of pins. Time to sniff!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702183110819/9218ad8e-c3cb-4419-b462-b10eb1d45ae3.jpeg" alt class="image--center mx-auto" /></p>
<p>Here is the communication for requesting a meter reading:</p>
<pre><code class="lang-plaintext">request : 24720D
response: 2401722C182F010000002563A5150900000100002515EE960000000103002201000002030022E803000004002A5500000D
</code></pre>
<p>Looking at <a target="_blank" href="https://github.com/jrouvier/esphome-emporia-vue-utility/blob/main/docs/protocol.md#receiving-responses">the docs</a> again, if we take out the header and footer bytes we can see that it is the same 44-byte payload I saw while running ESPHome, so that payload is expected and valid! Fantastic, now to analyze the bytes.</p>
<h2 id="heading-identify-the-bytes-of-interest">Identify the bytes of interest</h2>
<p>Let's compare three readings to see which bytes change between them:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702174597992/1bfe3aae-4460-4bdc-a9a2-064e11da51b0.png" alt class="image--center mx-auto" /></p>
<p>There are only 3 (plus an incrementing byte) groups of bytes that ever change, so what are they?</p>
<p>Let's call the incrementing byte "H0", and the three groups shown above can be H1, H2, and H3. Let's also swap their endianness. We can also take the reported watts from the Emporia app to know what value we should be expecting.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702178985458/29d23aa6-46c1-4ef2-be45-7a4962a197b5.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-hex-group-3">Hex Group 3</h3>
<p>H3 is clearly related to the wattage reading because all the negative wattages start with F's, but what's up with 33.7 and 45.3? According to H3 they're supposed to be negative... and the other calculated wattages are close-but-not-equal to the reported wattage. What's going on here?</p>
<p>The data above were hand-picked, but I think we need to look at many in a row to help detect patterns. So, I created a <a target="_blank" href="https://github.com/nekorevend/esphome-emporia-vue-utility/tree/main/tools">quick and dirty tool</a>. See the pattern below?</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702181543992/5e4a1a94-d10d-4845-8575-b8b91fc75142.png" alt class="image--center mx-auto" /></p>
<p>The quoted kW doesn't line up with the calculated W value, but it is offset (for the most part)! In this case, it is offset by a minute. So either my system's clock or Emporia's clock is off. Couldn't be me — Emporia's clock must be off by a minute. 😇</p>
<p>This also explains the 33.7 and 45.3 values having negative hex values. Given they were so relatively close to 0, the adjacent minute's reading must have been negative.</p>
<p>Now what about the other hex groups?</p>
<h3 id="heading-hex-groups-1-amp-2">Hex Groups 1 &amp; 2</h3>
<p>Let's sort chronologically instead:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702182045998/06fe4628-3ad2-43bd-bf26-a80c22a26ebb.png" alt class="image--center mx-auto" /></p>
<p>What pattern do you see with H1 and H2?</p>
<p>Both columns have sections where they don't change (see the top rows of H2 and bottom rows of H1), but does that correlate with anything? That's right, H2 doesn't change when the wattage is positive, while H1 doesn't change when the wattage is negative. They also seem to increase in value over time (with rollover).</p>
<p>These must be counting up the cumulative watt-hour usage. But is it Wh or kWh? Let's convert some to decimal and check:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Time</strong></td><td><strong>Import</strong></td><td><strong>Watts (diff * 60)</strong></td><td><strong>Export</strong></td><td><strong>Watts (diff * 60)</strong></td></tr>
</thead>
<tbody>
<tr>
<td>11:18:38</td><td>170</td><td></td><td>13458</td><td></td></tr>
<tr>
<td>11:19:38</td><td>175</td><td>300</td><td>13458</td><td>0</td></tr>
<tr>
<td>11:20:38</td><td>179</td><td>240</td><td>13458</td><td>0</td></tr>
<tr>
<td>11:21:38</td><td>185</td><td>360</td><td>13458</td><td>0</td></tr>
<tr>
<td>11:22:38</td><td>188</td><td>180</td><td>13473</td><td>900</td></tr>
<tr>
<td>11:23:38</td><td>188</td><td>0</td><td>13525</td><td>3120</td></tr>
<tr>
<td>11:24:38</td><td>188</td><td>0</td><td>13579</td><td>3240</td></tr>
</tbody>
</table>
</div><p>In this table, each row is one minute after the previous row. If we take the difference between two rows (a watt-minute) and multiply it by 60, we can convert it to instantaneous watts for that minute. This should match the actual wattage reading at the time, and it does! So I can confirm that H1 is <strong>Watt-hours consumed</strong> while H2 is <strong>Watt-hours produced</strong>.</p>
<h4 id="heading-update-2024-01-11">UPDATE (2024-01-11)</h4>
<p>Upon seeing more data, I've concluded that H1 and H2 are actually four bytes each, not two. I will keep the original writing above but have updated the remainder of the article below.</p>
<h2 id="heading-why-didnt-jrouviers-code-work">Why didn't jrouvier's code work?</h2>
<p>Their work was based on the returned payload from the MGM111 firmware version 2, but mine shipped with version 7. I've documented the difference in the payload <a target="_blank" href="https://github.com/nekorevend/esphome-emporia-vue-utility/blob/main/docs/protocol-meter-reading.md#payload-for-version-7">here</a>.</p>
<p>TL;DR, this is the V7 payload format:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1705047800986/d05b4f23-9689-4ec8-8cbe-7160df28232e.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-show-me-the-code">Show me the code</h2>
<p>Here you go: <a target="_blank" href="https://github.com/nekorevend/esphome-emporia-vue-utility">https://github.com/nekorevend/esphome-emporia-vue-utility</a></p>
<p>And it works!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1702182865281/e7cf99c7-ad77-455c-b52e-c00c2cc1f0fd.png" alt class="image--center mx-auto" /></p>
]]></content:encoded></item><item><title><![CDATA[Humidity Readings - A Rabbit Hole]]></title><description><![CDATA[I've been getting sensors like this that can measure temperature and relative humidity. Coupled with microcontroller boards like the Raspberry Pi Pico W running ESPHome, I'm able to create small USB-powered wireless thermometer/hygrometer units that ...]]></description><link>https://victorchang.codes/humidity-readings-a-rabbit-hole</link><guid isPermaLink="true">https://victorchang.codes/humidity-readings-a-rabbit-hole</guid><category><![CDATA[Humidity Calibration]]></category><category><![CDATA[esphome]]></category><category><![CDATA[Wifi Humidity Sensor]]></category><category><![CDATA[datahoarding]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Sat, 09 Sep 2023 21:26:52 GMT</pubDate><content:encoded><![CDATA[<p>I've been getting sensors like <a target="_blank" href="https://sensirion.com/products/catalog/SHT40">this</a> that can measure temperature and relative humidity. Coupled with microcontroller boards like the <a target="_blank" href="https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html">Raspberry Pi Pico W</a> running <a target="_blank" href="https://esphome.io/">ESPHome</a>, I'm able to create small USB-powered wireless thermometer/hygrometer units that I put in various rooms/locations.</p>
<p>With the combination of <a target="_blank" href="https://www.influxdata.com/blog/how-integrate-gafana-home-assistant/">Home Assistant / InfluxDB / Grafana</a>, I can record and visualize historical data:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689541486171/f47c8659-2486-42e4-a943-fa33f52ab18a.png" alt class="image--center mx-auto" /></p>
<p>If you like #datahoarding, this is great fun.. right?</p>
<p><strong><em>Side note</em></strong>: <em>I'm focusing on humidity readings for this post because calibrating humidity has been a rabbit hole while calibrating the temperature has been more straightforward. That said, the</em> <a target="_blank" href="https://github.com/nekorevend/thermohygrometer-calibration"><em>tool</em></a> <em>I provide will calibrate temperature as well.</em></p>
<h1 id="heading-descent-into-madness">Descent into Madness</h1>
<p>There's an adage called <a target="_blank" href="https://en.wikipedia.org/wiki/Segal%27s_law">Segal's law</a> that states:</p>
<blockquote>
<p>A man with a watch knows what time it is. A man with two watches is never sure.</p>
</blockquote>
<p>Well, I have way more than one sensor and they certainly don't agree with each other. Here are several of them installed very closely together on a single breadboard to ensure they are measuring the same environment.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689733911152/46a2a9a5-11fe-42d5-a147-c5ed7262175e.png" alt class="image--center mx-auto" /></p>
<p>Here's their data on a timeline:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689734247273/2e7318c9-a57f-40d8-96f1-d5938c7983bb.png" alt class="image--center mx-auto" /></p>
<p>They've formed two groups of measurements, but which group is correct?</p>
<p>... Is either group correct?</p>
<p>Not a great situation. There's no point in hoarding data that isn't even accurate. So what can be done? Follow me on my journey!</p>
<h2 id="heading-step-1-how-do-others-calibrate">Step 1: How do others calibrate?</h2>
<p>When searching about humidity calibration methodologies, I encountered an unexpected ally: cigar enthusiasts! Cigars must be stored in a particular humidity range (65-75%), so they have an interest in having a properly calibrated humidity sensor.</p>
<p>According to discussion boards and in <a target="_blank" href="https://www.youtube.com/watch?v=amSMJ2pUIdE">videos</a>, the technique is this:</p>
<ol>
<li><p>Use table salt (NaCl) and make it damp - like wet snow.</p>
</li>
<li><p>Seal it in a container along with your humidity sensor.</p>
</li>
<li><p>Wait several hours for the salt to regulate the humidity in the box to about 75%.</p>
</li>
<li><p>Observe the humidity reported by the sensor.</p>
</li>
<li><p>Tweak the sensor up or down by the appropriate amount to make it say 75%.</p>
</li>
<li><p>Done!</p>
</li>
</ol>
<p>I couldn't use a lid with my Tupperware container (like in the video) because I need to run a power cord, so this is my setup:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694071440205/fdd4685e-de9c-4b2c-8e72-6158de4ab987.jpeg" alt class="image--center mx-auto" /></p>
<p><strong>&lt;Tangent&gt;</strong></p>
<p>The photo above shows that I sealed the sensors and salt in a Ziploc bag. I want to take a quick aside to mention that it took me weeks of container iterations to settle on that method of having a well-sealed container. The bag's only hole is a corner I cut to run a power cord through, but that corner was subsequently taped back shut.</p>
<p>I also tried using a Qi wireless <em>receiver</em> to avoid even needing to cut a hole for the power cord at all, but the heat generated by wireless charging proved to be too much.</p>
<p>All of my prior container "designs" had (bad enough) leaks that led to inconsistent readings, such as with my earliest approach of holding a plastic wrap down with a rubber band:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689733749039/48f94a08-aa69-418b-a85f-0ae3c6660f74.png" alt class="image--center mx-auto" /></p>
<p>Don't do that. Try using a Ziploc bag instead.</p>
<p>Anyway, back to talking about the salt calibration method!</p>
<p><strong>&lt;/Tangent&gt;</strong></p>
<p>This method works fine for cigars because table salt happens to regulate a humidity (75%) that fits the ideal range for cigars (65-75%). So, this single-point calibration is good enough.</p>
<h3 id="heading-whats-the-issue">What's the issue?</h3>
<p>I am looking for accurate measurements across the entire spectrum that I'd encounter at home. The single-point calibration method has a major weakness: you don't know if the sensor's inaccuracy is uniform across the spectrum.</p>
<p>In other words, if at 75% the sensor reads 70%, then that's a simple +5 to calibrate it. But would that sensor report 30% when the reality is 35%? Spoiler: no.</p>
<h2 id="heading-step-2-can-i-calibrate-with-more-than-one-point">Step 2: Can I calibrate with more than one point?</h2>
<p><a target="_blank" href="https://www.engineeringtoolbox.com/salt-humidity-d_1887.html">Different salts maintain different humidity levels</a>. Let's add a second point of reference. I chose to use Magnesium Chloride (MgCl<sub>2</sub>) because at 33% it allows covering a reasonable spectrum alongside NaCl's 75%. Compared to other low-humidity salt options, MgCl<sub>2</sub> is also easier to get. I could find it on Amazon while others were special enough to only be sold by dedicated science supply stores.</p>
<p><em>Using more than two points of reference would be nice. But for practical purposes, I am going to limit to only using NaCl and MgCl<sub>2</sub>... at least for now.</em></p>
<p>So how does the data look?</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694069764915/9c57d0b6-042b-4e51-b93d-1f9f67983c1b.png" alt class="image--center mx-auto" /></p>
<p>We can see that all of the sensors tend to read higher on the low end and lower on the high end. The "F" and "G" sensors happen to be too high (almost 40% instead of 33%) while being spot on at 75%, while the other sensors are spot on at 33% but deviate to about 71% when they should report 75%.</p>
<p>ESPHome provides a few calibration functions, and a basic one we can use here is <a target="_blank" href="https://esphome.io/components/sensor/index.html#calibrate-linear"><code>calibrate_linear</code></a>. So I can just add something like this to the .yaml config:</p>
<pre><code class="lang-yaml">    <span class="hljs-attr">filters:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">calibrate_linear:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">39.386</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">33.613</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">75.231</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">75.500</span>
</code></pre>
<p>Easy, right? But wait, that wasn't so bad... was the rabbit hole just learning how to seal the container? Unfortunately not. 🙃</p>
<h3 id="heading-relative-humidity">Relative Humidity</h3>
<p>The humidity percentages I've been talking about are a "relative humidity" value. They are <strong>relative</strong> to the current temperature. So if anything, I've only defined the calibration for humidity specifically at my room temperature (77.5°F / 25.278°C). If I were to seriously calibrate the humidity readings, I would have to use a formula that considers both temperature <strong>and</strong> humidity.</p>
<h2 id="heading-step-3-gather-humidity-values-at-multiple-temperatures">Step 3: Gather humidity values at multiple temperatures</h2>
<p>Fortunately, calibrating using salts still works here. As I linked earlier, there exist tables that define the temperature-to-humidity relationship for each salt.</p>
<p>I went with <a target="_blank" href="https://www.semanticscholar.org/paper/Humidity-Fixed-Points-of-Binary-Saturated-Aqueous-Greenspan/9a90c8e8fb71c152ae3bacf9904e6c761cdf9de7/figure/1">this table</a> because it gives more precision (to the hundredths place).</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Celsius</td><td>NaCl</td><td>MgCl<sub>2</sub></td></tr>
</thead>
<tbody>
<tr>
<td>10°</td><td>75.67%</td><td>33.47%</td></tr>
<tr>
<td>15°</td><td>75.61%</td><td>33.30%</td></tr>
<tr>
<td>20°</td><td>75.47%</td><td>33.07%</td></tr>
<tr>
<td>25°</td><td>75.29%</td><td>32.78%</td></tr>
<tr>
<td>30°</td><td>75.09%</td><td>32.44%</td></tr>
<tr>
<td>35°</td><td>74.87%</td><td>32.05%</td></tr>
</tbody>
</table>
</div><p>While the humidity level changes with temperature, it changes by very little.</p>
<p>As for what kind of temperatures I could realistically produce with any sense of long-term stability without laboratory equipment... I opted for room temperature and the refrigerator, which are 25 and 4°C, respectively. So how do the sensors vary depending on temperature?</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694073221443/f1d64ab1-6678-413e-b6de-9cef9a40edef.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1694073270560/e4147386-6221-428a-b908-9480c84db08b.png" alt class="image--center mx-auto" /></p>
<p>Most of their slopes don't match the Expected slope, so we need to apply a temperature-dependent adjustment.</p>
<h2 id="heading-step-4-interpolations">Step 4: Interpolations</h2>
<p>We need to make a few interpolations to be able to identify the adjustments needed (for each sensor) to derive the correct humidity at any given temperature. To do so, we consider:</p>
<ol>
<li><p>For any given temperature, what are the <strong>expected</strong> humidities for NaCl and MgCl<sub>2</sub>?</p>
</li>
<li><p>For any given temperature, what are the incorrect humidities <strong>reported by the sensor</strong> for NaCl and MgCl<sub>2</sub>?</p>
</li>
<li><p>With those two figured out, what is the necessary <strong>correction slope</strong> to address humidities that aren't 33 or 75%?</p>
</li>
</ol>
<h3 id="heading-1-reference-humidities-at-any-arbitrary-temperature">1. Reference Humidities at any Arbitrary Temperature</h3>
<p>The original humidity table I used only provides humidities at 5° intervals. I let Google Sheets derive polynomial trendlines to match this data.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1689657377985/92a64667-3993-46d5-8468-22a6ebdeb111.png" alt class="image--center mx-auto" /></p>
<p>For a given temperature <em>x</em>, the humidity with NaCl would be calculated with this:</p>
<p>$$33.697 - (7.98 \times 10^{-3})x - (1.09 \times 10^{-3})x^2 - (9.71 \times 10^{-9})x^3$$</p><p>For MgCl<sub>2</sub>, it'd be this:</p>
<p>$$75.51 + 0.0396x - (2.65 \times 10^{-3})x^2 + (2.84 \times 10^{-5})x^3$$</p><h3 id="heading-2-reported-humidities-at-any-arbitrary-temperature">2. Reported Humidities at any Arbitrary Temperature</h3>
<p>As mentioned before, I will only be trying two temperature points: 4 and 25°C. We will just have a linear relationship between the two measurements.</p>
<p>Unfortunately, it'd be awkward to try to use ESPHome's <code>calibrated_linear</code> because it isn't available as a function call. Instead, we can use a <code>segmented_linear</code> implementation I had written in the past. You can read about it <a target="_blank" href="https://victorchang.codes/segmented-linear-calibration-for-esphome">here</a>!</p>
<p>Yes, it was intended for inputting three or more points, but it will behave identically to <code>calibrated_linear</code> when given two points and is available as a simple function call.</p>
<h3 id="heading-3-corrected-humidity-outside-of-33-or-75">3. Corrected Humidity outside of 33 or 75%.</h3>
<p>Let this just be a linear relationship based on the offsets that need to be done to each sensor at 33 and 75% humidity. We will use <code>segmented_linear</code> here as well.</p>
<h2 id="heading-step-5-implementation">Step 5: Implementation</h2>
<p>The calibration options built into ESPHome are inadequate for this task, but they allow calling C++ code in a lambda. So let's make a custom function! What are our inputs?</p>
<ul>
<li><p>Temperature.</p>
<ul>
<li>Correct temperature, post-calibration.</li>
</ul>
</li>
<li><p>Humidity.</p>
<ul>
<li>Original reading, pre-calibration.</li>
</ul>
</li>
<li><p>Function to calculate the <strong>expected</strong> humidity for a given temperature.</p>
<ul>
<li><p>Need at least two of these at different humidity levels to have an adjustment slope/curve on the spectrum.</p>
</li>
<li><p>For my needs, I will just implement support for two points.</p>
</li>
</ul>
</li>
<li><p>Function to calculate the <strong>reported</strong> humidity for a given temperature.</p>
<ul>
<li>Needs to be a matching set to coincide with the same respective <em>expected</em> humidities.</li>
</ul>
</li>
</ul>
<p>So we need two floats and four lambdas.</p>
<pre><code class="lang-cpp"><span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">calibrated_humidity</span><span class="hljs-params">(
        <span class="hljs-keyword">float</span> temp,
        <span class="hljs-keyword">float</span> hum,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;expected1,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;expected2,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;measured1,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;measured2)</span> </span>{
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>We'll also need helper functions to derive the linear fit line:</p>
<pre><code class="lang-cpp"><span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">correlation_coefficient</span><span class="hljs-params">(<span class="hljs-keyword">float</span> x1, <span class="hljs-keyword">float</span> x2, <span class="hljs-keyword">float</span> y1, <span class="hljs-keyword">float</span> y2)</span> </span>{
    <span class="hljs-keyword">float</span> avg_x = (x1 + x2) / <span class="hljs-number">2</span>;
    <span class="hljs-keyword">float</span> avg_y = (y1 + y2) / <span class="hljs-number">2</span>;
    <span class="hljs-keyword">float</span> numerator = (x1 - avg_x) * (y1 - avg_y) + (x2 - avg_x) * (y2 - avg_y);
    <span class="hljs-keyword">float</span> denominator = <span class="hljs-built_in">sqrt</span>(
                            <span class="hljs-built_in">pow</span>(x1 - avg_x, <span class="hljs-number">2</span>) +
                            <span class="hljs-built_in">pow</span>(x2 - avg_x, <span class="hljs-number">2</span>)
                            ) * 
                        <span class="hljs-built_in">sqrt</span>(
                            <span class="hljs-built_in">pow</span>(y1 - avg_y, <span class="hljs-number">2</span>) +
                            <span class="hljs-built_in">pow</span>(y2 - avg_y, <span class="hljs-number">2</span>)
                        );
    <span class="hljs-keyword">return</span> numerator / denominator;
}

<span class="hljs-function"><span class="hljs-built_in">std</span>::<span class="hljs-built_in">pair</span>&lt;<span class="hljs-keyword">float</span>, <span class="hljs-keyword">float</span>&gt; <span class="hljs-title">linear_fit</span><span class="hljs-params">(<span class="hljs-keyword">float</span> x1, <span class="hljs-keyword">float</span> y1, <span class="hljs-keyword">float</span> x2, <span class="hljs-keyword">float</span> y2)</span> </span>{
    <span class="hljs-keyword">float</span> correlation_coefficient_value = correlation_coefficient(x1, y1, x2, y2);
    <span class="hljs-keyword">float</span> slope = correlation_coefficient_value * (y2 - y1) / (x2 - x1);
    <span class="hljs-keyword">float</span> intercept = y1 - slope * x1;
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">std</span>::<span class="hljs-built_in">make_pair</span>(slope, intercept);
}
</code></pre>
<p>So all together, our calibration call would be...</p>
<pre><code class="lang-cpp"><span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">calibrated_humidity</span><span class="hljs-params">(
        <span class="hljs-keyword">float</span> temp,
        <span class="hljs-keyword">float</span> hum,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;expected1,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;expected2,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;measured1,
        <span class="hljs-keyword">const</span> <span class="hljs-built_in">std</span>::function&lt;<span class="hljs-keyword">float</span>(<span class="hljs-keyword">float</span>)&gt; &amp;measured2)</span> </span>{
    <span class="hljs-built_in">std</span>::<span class="hljs-built_in">pair</span>&lt;<span class="hljs-keyword">float</span>, <span class="hljs-keyword">float</span>&gt; <span class="hljs-built_in">pair</span> = linear_fit(
            measured1(temp), 
            measured2(temp),
            expected1(temp),
            expected2(temp)
        );
    <span class="hljs-keyword">float</span> slope = <span class="hljs-built_in">pair</span>.first;
    <span class="hljs-keyword">float</span> intercept = <span class="hljs-built_in">pair</span>.second;
    <span class="hljs-keyword">return</span> (slope * hum) + intercept;
}
</code></pre>
<p>Let's put all of that into a <code>calibration.h</code> file. We can then use it in an ESPHome YAML configuration like so:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">esphome:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">my_sensor</span>
  <span class="hljs-attr">includes:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">calibration.h</span>
<span class="hljs-comment"># ...</span>
<span class="hljs-bullet">-</span> <span class="hljs-meta">&amp;hum_calibrate</span>
    <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
      static auto expected1 = [](float x) -&gt; float {
          return 33.67 - ((7.98 * pow(10, -3)) * x) - ((1.09 * pow(10, -3)) *
              pow(x, 2)) - ((9.71 * pow(10, -9)) * pow(x, 3));
      };
      static auto expected2 = [](float x) -&gt; float {
          return 75.51 + (0.0396 * x) - ((2.65 * pow(10, -3)) * pow(x, 2)) +
              ((2.84 * pow(10, -5)) * pow(x, 3));
      };
      static auto measured1 = [](float x) -&gt; float {
          return -0.22*x + 42.4;
      };
      static auto measured2 = [](float x) -&gt; float {
          return -0.453*x + 71.4;
      };
      return calibrated_humidity(
          id(my_calibrated_temperature).state,
          x, expected1, expected2, measured1, measured2
      );
</span><span class="hljs-comment"># ...</span>
</code></pre>
<h2 id="heading-step-6-calibrating-other-sensors">Step 6: Calibrating other sensors</h2>
<p>I'll admit, the salt test calibration process is tedious and annoying and I don't want to do it again. But I do want to calibrate my other sensors. I can take the salt-calibrated sensors as my source of truth and place the additional sensors near them so they measure the same environment. From there, it'd be a matter of calibrating the other sensors according to what the "true" sensors are reading.</p>
<p>But how can I confidently do this? Going back to how I use both the temperature <em>and</em> humidity readings to properly calibrate the humidity, I'd need at least four data points:</p>
<ol>
<li><p>Low humidity @ Low temperature</p>
</li>
<li><p>High humidity @ Low temperature</p>
</li>
<li><p>Low humidity @ High temperature</p>
</li>
<li><p>High humidity @ High temperature</p>
</li>
</ol>
<p>The problem is I don't have control over the humidity anymore due to not wanting to redo the salt test. It's a matter of luck to see what the weather gives me, and enough time for the weather to vary enough. Ultimately, I'm not in a hurry and the setup would be set-and-forget, so I can allow for weeks of data to be gathered.</p>
<h3 id="heading-procedure">Procedure</h3>
<p>Let's gather lots of data, and I will use my garage environment since it would have the widest swings (of at least the temperature). The data we care about are the calibrated temperatures and humidities from the calibrated sensors and raw readings from the uncalibrated sensors. The data will have a wave pattern on a daily cadence. Here are the calibrated sensors:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693042909109/69be0ce8-62a3-420b-9933-2c0db01a76f4.png" alt class="image--center mx-auto" /></p>
<p>You can see they pretty much agree with each other. The humidity graph has a wider delta between lines due to the sensors having a rated humidity margin of error of ±1.0-1.8% depending on model.</p>
<p>Now let's add a couple of uncalibrated humidity readings that we want to calibrate:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693043122194/d5fe929f-a804-4dbc-9cab-93f8cf6242c7.png" alt class="image--center mx-auto" /></p>
<p>The two new lines don't agree with the calibrated lines. We have our work set out for us.</p>
<p>We will want to:</p>
<ol>
<li><p>Choose two humidity points (from the calibrated sensors only), a low and a high.</p>
<ul>
<li><p>You might be tempted to find the widest difference between low and high, but by its nature, it is an outlier. An outlier will mean we only find one temperature for that humidity at the one time it occurs.</p>
<ul>
<li>We are looking for examples of low and high temperatures existing with the same humidity level, so it is a necessity to choose humidity levels that occur more than once.</li>
</ul>
</li>
<li><p>I think a reasonable method is to calculate the standard deviation and use the -1 and +1 stddev humidity values.</p>
</li>
</ul>
</li>
<li><p>Find all timestamps that have the chosen humidity points.</p>
</li>
<li><p>Get the lowest and highest observed temperatures among those timestamps, for each humidity point.</p>
</li>
<li><p>Once we have the timestamps for those temperatures, we need to get the respective humidity values at those timestamps from the sensors we want to calibrate.</p>
</li>
<li><p>Now we know both the expected and observed humidity levels for two different temperature levels at two different expected humidities. This is enough data to feed into my <code>calibrated_humidity()</code> function mentioned above.</p>
<ul>
<li>One difference is the <code>expected</code> lambdas were created to support the salt test, where the expected humidity changes depending on the temperature. But in this approach, we are matching based on the humidity. Naturally, the humidity level becomes a constant. Therefore, the two <code>expected</code> lambdas can just return a static value (either the low or high humidity, respectively).</li>
</ul>
</li>
</ol>
<p>Enter those values into the YAML like so:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
  static auto expected1 = [](float x) -&gt; float {
      return 38.002;
  };
  static auto expected2 = [](float x) -&gt; float {
      return 48.856;
  };
  static auto measured1 = [](float x) -&gt; float {
      static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
        // {Temperature, Humidity}
        {27.068, 40.548},
        {34.536, 40.816},
      };
      return segmented_linear(mapping, x);
  };
  static auto measured2 = [](float x) -&gt; float {
      static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
        // {Temperature, Humidity}
        {20.859, 49.383},
        {30.523, 48.517},
      };
      return segmented_linear(mapping, x);
  };
  return calibrated_humidity(
      id(temperature).state,
      x, expected1, expected2, measured1, measured2
  );</span>
</code></pre>
<p>Now the newly-calibrated sensors fit in with the rest!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693044847064/55d2df2a-667d-43c8-b197-fb2f42c0dd18.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-step-7-but-thats-so-much-data-to-process">Step 7: But that's so much data to process...</h2>
<p>I agree! No one should be doing that by hand, especially if you intend to calibrate multiple sensors. That's why I wrote <a target="_blank" href="https://github.com/nekorevend/thermohygrometer-calibration">a tool</a> to do it for me (and you!).</p>
<p>I've written two ways of using the script:</p>
<ol>
<li><p>Let it query your InfluxDB directly (<code>from_influx.py</code>).</p>
</li>
<li><p>Give it some CSV files with the same data we need (<code>from_csv.py</code>).</p>
</li>
</ol>
<h3 id="heading-from-csv">From CSV</h3>
<p>The CSV way is the easier place for us to start understanding the tool.</p>
<p>There are four collections of data we need to pass in:</p>
<ol>
<li><p>Reference Temperatures</p>
</li>
<li><p>Reference Humidities</p>
</li>
<li><p>Uncalibrated Temperatures</p>
</li>
<li><p>Uncalibrated Humidities</p>
</li>
</ol>
<p>The command would look like this:</p>
<pre><code class="lang-plaintext">python from_csv.py 
    --reference_temperature_csv ref_temp.csv
    --reference_humidity_csv ref_hum.csv
    --uncalibrated_temperature_csv uncal_temp.csv
    --uncalibrated_humidity_csv uncal_hum.csv
</code></pre>
<p>All of the CSV files must be in the format <code>SENSOR_NAME,TIMESTAMP,VALUE</code>. The value unit you use here doesn't matter as long as you understand that the output will be in the same units (which might not be the unit you need in your ESPHome config). The timestamp must be parsable by:</p>
<pre><code class="lang-python">datetime.strptime(input, <span class="hljs-string">'%Y-%m-%dT%H:%M:%SZ'</span>)
</code></pre>
<p>To be explicit, the tool is expecting <em>lots</em> of values. In my case, I'm using a 30-second interval. For a hypothetical week of data gathered, it would amount to 20,160 data points per file, per sensor. The script will interpolate between the gaps, but it's always better to have smaller gaps.</p>
<h3 id="heading-from-influxdb">From InfluxDB</h3>
<p>The tool can query the database directly to grab the same data as we'd have passed into the CSV. But naturally, in this case, you'd have to specify the entity IDs of the sensors as well as the date range you want to sample.</p>
<pre><code class="lang-plaintext">python from_influx.py
    --start_time "2023-08-31 07:00:00" --end_time "2023-09-03 07:00:00"
    --reference_temperature_sensors sensor_a_temp,sensor_b_temp
    --reference_humidity_sensors sensor_a_hum,sensor_b_hum
    --uncalibrated_temperature_sensors sensor_c_temp,sensor_d_temp
    --uncalibrated_humidity_sensors sensor_c_hum,sensor_d_hum
</code></pre>
<p>The start and end times expect UTC.</p>
<p><code>--output_to_csv</code> exists as well to simply output the CSV files that you could pass into <code>from_csv.py</code>.</p>
<p>You also need to create an <code>influx_config.py</code> file to contain the details needed for the tool to connect to your database instance.</p>
<pre><code class="lang-python">INFLUX_ORG=<span class="hljs-string">'your_org'</span>
INFLUX_BUCKET=<span class="hljs-string">'your_bucket'</span>  <span class="hljs-comment"># probably "homeassistant"</span>
INFLUX_TOKEN=<span class="hljs-string">'your_token'</span>
INFLUX_URL=<span class="hljs-string">'http://your_db_ip:8086'</span>
</code></pre>
<h3 id="heading-example-output">Example output</h3>
<p>Here's what you can expect to see for a given uncalibrated sensor. You can then copy and paste the <code>calibrate_linear</code> and <code>lambda</code> sections into the appropriate spots in your sensor's ESPHome YAML configuration.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">Sensor:</span> <span class="hljs-string">sensor_c_temp</span>

<span class="hljs-string">==========</span> <span class="hljs-string">Temperature</span> <span class="hljs-string">Calibration</span> <span class="hljs-string">==========</span>
<span class="hljs-attr">calibrate_linear:</span>
  <span class="hljs-attr">method:</span> <span class="hljs-string">exact</span>
  <span class="hljs-attr">datapoints:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">21.985</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">20.100</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">23.958</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">22.200</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">25.942</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">24.300</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">27.808</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">26.400</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">29.633</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">28.500</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">31.490</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">30.600</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">33.600</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">32.700</span>
    <span class="hljs-bullet">-</span> <span class="hljs-number">35.722</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">34.800</span>

<span class="hljs-string">===========</span> <span class="hljs-string">Humidity</span> <span class="hljs-string">Calibration</span> <span class="hljs-string">============</span>
<span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
  static auto expected1 = [](float x) -&gt; float {
    return 38.065;
  };
  static auto expected2 = [](float x) -&gt; float {
    return 48.819;
  };
  static auto measured1 = [](float x) -&gt; float {
    static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
      {23.116, 36.839}, {31.777, 38.114}
    };
    return segmented_linear(mapping, x);
  };
  static auto measured2 = [](float x) -&gt; float {
    static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
      {20.678, 44.844}, {28.367, 46.836}
    };
    return segmented_linear(mapping, x);
  };
  return calibrated_humidity(
    id(temperature).state,
    x, expected1, expected2, measured1, measured2
  );</span>
</code></pre>
<h3 id="heading-assumptions-made">Assumptions made</h3>
<p>To avoid complicating the logic, I've made some assumptions about what's being provided to the tool.</p>
<ol>
<li><p>The set of sensors given for <code>uncalibrated_temperature_sensors</code> and <code>uncalibrated_humidity_sensors</code> are identical. Same length and contains the same sensors.</p>
<ul>
<li><p>This means that your sensors are expected to be temperature+humidity combo sensors. This is true for me so I did not dive in further. If you are trying to calibrate sensors that are only one of these types... try to fake some dummy data. I would duplicate the data from another sensor and just change the name in the CSV, then use the <code>from_csv.py</code> route.</p>
<ul>
<li>Make sure the dummy name matches the format of the sensor you want to calibrate, see the next assumption below.</li>
</ul>
</li>
<li><p>Note that this restriction does not apply to the reference sensors. The tool will just average them together before using the data.</p>
</li>
</ul>
</li>
<li><p>Your sensor names are consistent between temperature and humidity. To match up the temperature and humidity entities for each sensor, I am assuming that the list of temperature entities and list of humidity entities would alpha-sort into the same order.</p>
<ul>
<li>The respective sensors at each index of both lists should end up referring to the same actual sensor.</li>
</ul>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Garage Door Controller and Sensor]]></title><description><![CDATA[Let's make a dumb garage door smart! The usual consumer solution is the myQ controller, but I generally dislike being dependent on some company's cloud service staying online. Instead, I will DIY a solution using ESPHome and Home Assistant.
Requireme...]]></description><link>https://victorchang.codes/garage-controller-and-sensor</link><guid isPermaLink="true">https://victorchang.codes/garage-controller-and-sensor</guid><category><![CDATA[esphome]]></category><category><![CDATA[garage door]]></category><category><![CDATA[sensors]]></category><category><![CDATA[controllers]]></category><category><![CDATA[control]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Fri, 18 Aug 2023 18:52:18 GMT</pubDate><content:encoded><![CDATA[<p>Let's make a dumb garage door smart! The usual consumer solution is the <a target="_blank" href="https://www.myq.com/products/smart-garage-control">myQ controller</a>, but I generally dislike being dependent on some company's cloud service staying online. Instead, I will DIY a solution using <a target="_blank" href="https://esphome.io/">ESPHome</a> and <a target="_blank" href="https://www.home-assistant.io/">Home Assistant</a>.</p>
<h2 id="heading-requirements">Requirements</h2>
<p>Setting some ground rules for what I care about.</p>
<ul>
<li><p>Must be able to tell if the door is opened or closed.</p>
</li>
<li><p>Can open/close the door.</p>
</li>
<li><p>Long-lasting set-and-forget.</p>
<ul>
<li><p>No batteries.</p>
</li>
<li><p>No moving parts.</p>
</li>
<li><p>Impervious to the dirty environment of a garage.</p>
</li>
<li><p>No dependence on some company's cloud service staying online.</p>
</li>
</ul>
</li>
<li><p>No subscription model.</p>
</li>
</ul>
<p>Note: The way the myQ solution can tell if the door is open is by having you attach a sensor to the door itself, but that sensor is battery-powered. No good!</p>
<h2 id="heading-design">Design</h2>
<h3 id="heading-control">Control</h3>
<p>I will need to be able to tell my garage door to open/close. But how?</p>
<h4 id="heading-the-button-side">The Button Side</h4>
<p>The dead simple way is to understand that all a button does is short two wires when pressed. If we disassemble the button on the wall, we can connect two more wires to that button and use our microcontroller to simulate a button press by shorting those wires.</p>
<p>But there's a catch. My wall button is not just a button like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692336626573/0cf57a2c-44f6-4c16-97de-3a20d2b3e3d1.png" alt class="image--center mx-auto" /></p>
<p>Mine has additional functions including light control and a motion sensor:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692336665353/0eee423f-d9f5-4a08-9401-eb2efe7e3616.png" alt class="image--center mx-auto" /></p>
<p>Note: The main button cap was already removed when I took this photo.</p>
<p>So, I couldn't simply connect two wires to the two "W" and "R" wires coming from the wall since they are not the correct wires we need to short:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692336806449/9028c0ae-6e37-4b04-9c6d-69e41a31a432.png" alt class="image--center mx-auto" /></p>
<p>Instead, I will need to solder my wires to the button itself. Specifically these pins:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692337035402/4d7ac788-126a-42d1-8561-0b4a65d4aeef.png" alt class="image--center mx-auto" /></p>
<p>Solder from the back:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692339231535/7f76c22d-62a6-4a56-9517-6f5e9af975b7.png" alt class="image--center mx-auto" /></p>
<p>Route the new wires out the side, and we're done with modding the wall control!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692337179939/a6b12880-ca1d-4b19-8604-42cb35973be6.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-the-microcontroller-side">The Microcontroller Side</h4>
<p>For some reason, the wall button is powered by something close to 20 volts AC. The microcontroller (a <a target="_blank" href="https://www.raspberrypi.com/products/raspberry-pi-pico/">Raspberry Pi Pico W</a>) operates at 3.3 volts DC on its GPIO, so we can't connect the wires directly to the microcontroller. Instead, we need to use a <a target="_blank" href="https://www.amazon.com/dp/B095YD3732">relay</a>. The microcontroller tells the relay when to short the wires while being isolated from the 20VAC power thanks to the relay.</p>
<p>Let's 3D print a case for the microcontroller:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692339735975/9f937e68-aa66-442d-8b25-704faae92ff3.png" alt class="image--center mx-auto" /></p>
<p>The relay is on the bottom-left and the microcontroller is on the right, but let's get to what the thing at the top is next!</p>
<h3 id="heading-sensing-the-door">Sensing the Door</h3>
<p>My go-to for this would be an accelerometer sensor that can detect when it's vertical (closed) or horizontal (opened). Such a sensor would need to attach to the door itself. Since the door moves, we wouldn't be able to (easily) power a door-mounted sensor via wire. The logical answer is to use a battery-powered sensor like the myQ does, but I refuse to be beholden to dealing with low batteries. How can we sense that the door is open without needing to attach a sensor to the door?</p>
<p>How about putting a sensor on the wall near the door? It could work, but how would we sense the door? The options are...</p>
<ol>
<li><p>Use an <a target="_blank" href="https://www.sparkfun.com/products/15569">Ultrasonic Distance Sensor</a> to sense if something (the door) is nearby. But that doesn't have long-term reliability. The sensing element can be blocked by dirt over time or a spider could move into just the wrong place.</p>
</li>
<li><p>Use light-based sensors to do fundamentally the same thing as the ultrasonic sensor. It carries all the same issues plus one more: a bright sunny day could overload the sensor's ability to see its own light beacon.</p>
</li>
<li><p>A mechanical switch could be triggered by the door being in position or not. But anything with moving parts will wear down so that won't last forever.</p>
</li>
<li><p>Lastly, magnets! I can just place a magnet on the door and have a sensor on the wall to detect if a magnet is nearby. It is impervious to dirt, bugs, and light. The clear choice!</p>
</li>
</ol>
<p>Let's go with this <a target="_blank" href="https://www.sparkfun.com/products/14709">magnet sensor</a> and <a target="_blank" href="https://www.sparkfun.com/products/8643">magnet</a>. But that sensor is tiny and bare, how can we mount it? How would we wire it?</p>
<p>As you can see, the sensor has 3 pins, so we would need to run a cable with at least 3 wires, and the cable would have to be relatively long to reach the microcontroller that I will install near the garage door wall button. Does anything fit the bill? How about a regular 3.5mm audio cable!</p>
<p>Let's also get this <a target="_blank" href="https://www.sparkfun.com/products/11570">3.5mm jack breakout board</a>. We can then solder the sensor to it along with a pullup resistor like so:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692339065650/2b02a380-7079-4219-ae5b-48a0b4ae119b.png" alt class="image--center mx-auto" /></p>
<p>3D print a case for it to position the sensor at the right spot:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692340641152/1b0a94c5-e352-4be4-a2eb-784e9857b21b.png" alt class="image--center mx-auto" /></p>
<p>Oh yeah, remember that third thing at the top in the microcontroller box? It's the jack for the other end of the audio cable!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692340717993/cd7d7a53-347d-49c7-9f0c-de888da43277.png" alt class="image--center mx-auto" /></p>
<p>There's a hiccup though, this angle turns out to be bad:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692340807157/1c8bf0a8-97a1-4b9f-a432-ab141e992227.png" alt class="image--center mx-auto" /></p>
<p>Not a big deal, just needed to use a wedge shape to give it an angle:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692340899551/2d785bd0-01cc-45b8-aabd-47c425146e03.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-final-hardware">Final Hardware</h2>
<p>Now all we need to do is mount everything else and wire it all up!</p>
<p>Closeup of my crude wiring:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692341060009/58502e8c-18b1-437a-b4ac-77911476cc64.jpeg" alt class="image--center mx-auto" /></p>
<p>The box on the wall:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692341133606/d211e819-071c-4032-9e46-ec529de456dc.png" alt class="image--center mx-auto" /></p>
<p>You can see the two wires from the wall button going to the relay in the box, the audio cable plugging into the top, a microUSB cable to power the microcontroller, and hey, what's that bunch of wires coming out of the bottom of the box?</p>
<p>Not going to get into it for this article, but I figure I might as well have a temperature and humidity sensor for the garage. It just hangs out of the box to guarantee that the heat from the microcontroller will not impact the thermometer.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692341356316/9f104e22-a889-4336-ad1d-6035afb7c668.png" alt class="image--center mx-auto" /></p>
<p>Before you say it, I didn't care to organize anything since the garage is already a mess. Sorry, not sorry. 🙂</p>
<h2 id="heading-software">Software</h2>
<p>As I said at the beginning, I will use <a target="_blank" href="https://esphome.io/">ESPHome</a> and <a target="_blank" href="https://www.home-assistant.io/">Home Assistant</a>. ESPHome is a microcontroller firmware that abstracts away boilerplate logic for most IoT needs and allows you to easily configure something like my garage controller using just a YAML config.</p>
<p>Here is the full basic YAML (scroll to the bottom to skip the boilerplate):</p>
<pre><code class="lang-yaml"><span class="hljs-attr">esphome:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">garage-controller</span>

<span class="hljs-attr">rp2040:</span>
  <span class="hljs-attr">board:</span> <span class="hljs-string">rpipicow</span>
  <span class="hljs-attr">framework:</span>
    <span class="hljs-comment"># Required until https://github.com/platformio/platform-raspberrypi/pull/36 is merged</span>
    <span class="hljs-attr">platform_version:</span> <span class="hljs-string">https://github.com/maxgerhardt/platform-raspberrypi.git</span>

<span class="hljs-comment"># Enable logging</span>
<span class="hljs-attr">logger:</span>

<span class="hljs-comment"># Enable Home Assistant API</span>
<span class="hljs-attr">api:</span>
  <span class="hljs-attr">encryption:</span>
    <span class="hljs-attr">key:</span> <span class="hljs-string">"&lt;your key&gt;"</span>

<span class="hljs-attr">ota:</span>
  <span class="hljs-attr">password:</span> <span class="hljs-string">"&lt;your password&gt;"</span>

<span class="hljs-attr">wifi:</span>
  <span class="hljs-attr">ssid:</span> <span class="hljs-type">!secret</span> <span class="hljs-string">wifi_ssid</span>
  <span class="hljs-attr">password:</span> <span class="hljs-type">!secret</span> <span class="hljs-string">wifi_password</span>

<span class="hljs-attr">substitutions:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">"Garage"</span>
  <span class="hljs-attr">garage:</span> <span class="hljs-string">"Garage Door"</span>

<span class="hljs-attr">i2c:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">i2c_a</span>
    <span class="hljs-attr">sda:</span> <span class="hljs-string">GPIO20</span>
    <span class="hljs-attr">scl:</span> <span class="hljs-string">GPIO21</span>

<span class="hljs-attr">sensor:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">sht3xd</span>
    <span class="hljs-attr">temperature:</span>
      <span class="hljs-attr">name:</span> <span class="hljs-string">"${name} Temperature"</span>
      <span class="hljs-attr">accuracy_decimals:</span> <span class="hljs-number">2</span>
    <span class="hljs-attr">humidity:</span>
      <span class="hljs-attr">name:</span> <span class="hljs-string">"${name} Humidity"</span>
      <span class="hljs-attr">accuracy_decimals:</span> <span class="hljs-number">2</span>
    <span class="hljs-attr">address:</span> <span class="hljs-number">0x44</span>
    <span class="hljs-attr">update_interval:</span> <span class="hljs-string">1s</span>

<span class="hljs-attr">switch:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">gpio</span>
    <span class="hljs-attr">pin:</span> 
      <span class="hljs-attr">number:</span> <span class="hljs-string">GPIO22</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">garage_door_relay</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">"${garage} Remote"</span>
    <span class="hljs-attr">icon:</span> <span class="hljs-string">"mdi:garage"</span>
    <span class="hljs-attr">on_turn_on:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">delay:</span> <span class="hljs-string">200ms</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">switch.turn_off:</span> <span class="hljs-string">garage_door_relay</span>

<span class="hljs-attr">binary_sensor:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">gpio</span>
    <span class="hljs-attr">pin:</span>
      <span class="hljs-attr">number:</span> <span class="hljs-string">GPIO8</span>
    <span class="hljs-attr">id:</span> <span class="hljs-string">garage_door_status</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">"${garage} Status"</span>
    <span class="hljs-attr">device_class:</span> <span class="hljs-string">garage_door</span>
    <span class="hljs-attr">filters:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">delayed_on:</span> <span class="hljs-string">200ms</span>
</code></pre>
<p>The <code>switch:</code> and <code>binary_sensor:</code> sections are all we needed to add to support toggling the garage door and know if the door is open. Simple!</p>
<p>Here they are in the Home Assistant UI:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692343095217/1819ccc3-8c7e-4856-a84f-5b05c086d43b.png" alt class="image--center mx-auto" /></p>
<p>And they're fully available for automation, such as automatically closing the door if it's been open for too long.</p>
<p>I can also see a timeline of when the door's been open!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1692342130907/3a9381c0-d275-4a4e-b647-65c59a49003c.png" alt class="image--center mx-auto" /></p>
<p>Perfect, all I've ever wanted for my garage!</p>
]]></content:encoded></item><item><title><![CDATA[Quickly Detect Gaps in Dashcam Footage]]></title><description><![CDATA[Dashcams save their footage in multiple progressive files, but there's a common flaw that goes largely unnoticed: there can be a gap in footage between each file. This lost footage can be a few seconds long, and you never know when some emergency eve...]]></description><link>https://victorchang.codes/quickly-detect-gaps-in-footage</link><guid isPermaLink="true">https://victorchang.codes/quickly-detect-gaps-in-footage</guid><category><![CDATA[dash cam]]></category><category><![CDATA[gap]]></category><category><![CDATA[dashcam]]></category><category><![CDATA[cam]]></category><category><![CDATA[footage]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Sat, 29 Jul 2023 21:56:10 GMT</pubDate><content:encoded><![CDATA[<p>Dashcams save their footage in multiple progressive files, but there's a common flaw that goes largely unnoticed: there can be a gap in footage between each file. This lost footage can be a few seconds long, and you never know when some emergency event will happen (the entire point of a dashcam always recording). It would be terrible if the event happens during one of those recording gaps.</p>
<h3 id="heading-eh-the-files-look-fine-to-me">"Eh, the files look fine to me..."</h3>
<p>Unfortunately, it's hard to notice that it's happening unless you happen to be watching some footage and notice that the next file doesn't start where the previous file left off.</p>
<p>Some file managers display the "date modified" timestamp without seconds, so it's impossible to tell at a glance. Even if it did have seconds, scanning through the list and judging what the expected seconds would be for each file sounds tedious at best.</p>
<h3 id="heading-i-checked-a-few-by-hand-it-seems-like-my-camera-doesnt-have-the-issue">"I checked a few by hand, it seems like my camera doesn't have the issue."</h3>
<p>This can be a "sometimes" problem. I have personally experienced missing footage and discovered that it correlates with high-activity scenes. When a lot is happening on camera, the bitrate of the recording is higher. In this situation, the dashcam and/or memory card don't always keep up well enough to start the next recording promptly. And the joke's on us: an emergency event <em>probably</em> qualifies as a high-activity scene...</p>
<p>Let's make a tool to automatically flag these gaps in recordings. That way we can troubleshoot and attempt to fix it before we regret missing footage when it matters most!</p>
<h2 id="heading-approach">Approach</h2>
<p>We don't need to parse much data from each video to figure this out. By using the video's timestamp as well as the video duration, we know when the next video's timestamp is <em>expected</em> to be.</p>
<h2 id="heading-known-limitation">Known Limitation</h2>
<p>Since the timestamp only has a precision down to the second, we won't be able to detect gaps smaller than a second. We have to accept this limitation. This also means the precision at which we can calculate the gap is limited. A recording can start at, say, 12:00:00.678 but have a timestamp of 12:00:00. Our calculated gap would not be able to factor in the 678ms.</p>
<p>But that's fine since the real goal is to know that gaps have occurred rather than know the exact gap size down to the millisecond.</p>
<h2 id="heading-lets-write-it">Let's write it!</h2>
<p>But first a quick note, I am assuming that the camera has named the files in some sequential order that follows an alphanumeric sort. So, the tool will simply alpha-sort (using a priority queue because it's faster) the file paths and parse them in that order. <code>os.walk()</code> might already return the paths in the order we want, but I will still sort it to be safe.</p>
<p>Each file is then compared to the previous file and the gap between them is calculated.</p>
<p>Here it is <a target="_blank" href="https://github.com/nekorevend/Footage-Gap-Detector">on GitHub</a>!</p>
<p>And this is the tool's output:</p>
<pre><code class="lang-plaintext">$ python3 detect.py -d "/tank1/cameras/A119S"
Detected 6408ms gap between ".../2017_0114_141750_008.MP4" and ".../2017_0114_141805_009.MP4"
Detected 3520ms gap between ".../2017_0114_141805_009.MP4" and ".../2017_0114_141816_010.MP4"
Detected 1000ms gap between ".../2017_0116_121056_017.MP4" and ".../2017_0116_121557_018.MP4"
Detected 1000ms gap between ".../2017_0205_153459_307.MP4" and ".../2017_0205_153959_308.MP4"
Detected 1000ms gap between ".../2017_0226_175522_708.MP4" and ".../2017_0226_180023_709.MP4"
Detected 1000ms gap between ".../2017_0402_204918_285.MP4" and ".../2017_0402_205419_286.MP4"
Detected 9528ms gap between ".../2017_0708_104906_511.MP4" and ".../2017_0708_104917_512.MP4"
Detected 1000ms gap between ".../2017_0708_110435_516.MP4" and ".../2017_0708_110935_517.MP4"
Detected 1000ms gap between ".../2017_0716_184607_697.MP4" and ".../2017_0716_185108_698.MP4"
Detected 1000ms gap between ".../2017_0803_201801_997.MP4" and ".../2017_0803_202302_998.MP4"
</code></pre>
<p>Looks like it works! We have gaps though. What can be done?</p>
<h2 id="heading-fixing-your-dashcam">Fixing your dashcam</h2>
<p>Just some general tips.</p>
<h3 id="heading-lower-your-recording-quality">Lower your recording quality</h3>
<p>In other words, use a lower bitrate. It might help to lower your recording quality so in a high-activity scene the raised bitrate is still within the capabilities of your device/storage.</p>
<h3 id="heading-use-a-constant-bitrate-cbr">Use a constant bitrate (CBR)</h3>
<p>This means high-activity scenes will not cause the bitrate to increase.</p>
<h3 id="heading-reformat-your-memory-card">Reformat your memory card</h3>
<p>It's possible for prolonged use to bog down the filesystem on the card, where its metadata/access tables are not as efficiently organized (or something. I'm not an expert). A reformat would give the filesystem a fresh start. I know some dashcams even have an automated reminder to reformat the card periodically.</p>
<h3 id="heading-get-a-faster-memory-card">Get a faster memory card</h3>
<p>This is assuming the bottleneck is the card and not the dashcam.</p>
<h3 id="heading-get-a-better-dashcam">Get a better dashcam</h3>
<p>If all else fails. 🙂</p>
]]></content:encoded></item><item><title><![CDATA[Automating Incremental Backups (to AWS)]]></title><description><![CDATA[We should all have some sort of off-site backup of our data. My approach to achieving this involved writing a script which I will talk about in this article. Perhaps others will find it useful!
Background
I run a Network-Attached Storage (NAS) comput...]]></description><link>https://victorchang.codes/automating-incremental-backups-to-aws</link><guid isPermaLink="true">https://victorchang.codes/automating-incremental-backups-to-aws</guid><category><![CDATA[zfs]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Backup]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Wed, 26 Jul 2023 22:00:33 GMT</pubDate><content:encoded><![CDATA[<p>We should all have some sort of off-site backup of our data. My approach to achieving this involved writing <a target="_blank" href="https://github.com/nekorevend/zfs-to-aws">a script</a> which I will talk about in this article. Perhaps others will find it useful!</p>
<h2 id="heading-background">Background</h2>
<p>I run a Network-Attached Storage (NAS) computer at home using ZFS on Linux.</p>
<p>ZFS supports quick and easy snapshotting thanks to its copy-on-write design. These snapshots allow outputting diffs between any two arbitrary snapshots. This is how the incremental nature of my backups work!</p>
<h2 id="heading-requirements">Requirements</h2>
<p>I have goals for these backups that leads me to conclude that I must roll my own solution. Here are some of the goals:</p>
<h3 id="heading-self-encryption">Self-Encryption</h3>
<p>I have no intent to believe any claims like...</p>
<blockquote>
<p>Your data is private and encrypted by us and we promise we will never look at it!</p>
</blockquote>
<p>or even...</p>
<blockquote>
<p>We encrypt it with your encryption key and only you will ever know what the key is. Therefore, your data is private!</p>
</blockquote>
<p>Aside from completely impractical wishful thinking, I will never be able to fully vet (and continue vetting every update!) that their software isn't saving my key or doing any other nefarious thing. So, my approach is designed in a way that I'd be comfortable sending my backups straight to <em>&lt;insert your adversarial organization of choice here&gt;</em>!</p>
<h3 id="heading-fault-tolerant">Fault-Tolerant</h3>
<p>Let's assume the cloud provider can have <a target="_blank" href="https://en.wikipedia.org/wiki/Data_degradation">bit-rot</a> that they can't/don't fix. We must handle this ourselves.</p>
<h3 id="heading-compatible">Compatible</h3>
<p>Perhaps a legacy mindset, but you never know if a cloud provider will decide to have a max file size limit that is low enough to be a problem. So to be safe, I am following the lead of FAT32's max file size and am ensuring my backups are split into 4GiB files.</p>
<p>Also, none of the tools used to create/read the backup should be proprietary to any closed-source platform.</p>
<h2 id="heading-considerations">Considerations</h2>
<p>I have about 10TB of data to back up and that is sure to grow.</p>
<h3 id="heading-internet">Internet</h3>
<p>Your internet connection can be a bottleneck, both in speed and whether you have a data cap. I am fortunate enough to have symmetric fiber internet with no cap, but if you have limited internet access then this article isn't suitable. Some providers allow you to physically ship hard drives with your data, which you should consider.</p>
<h3 id="heading-storage-local">Storage (local)</h3>
<p>In my design, we're creating an entire backup of the data right on the system we're backing up. This requires the system to have as much free space available as the size of the dataset that we are backing up. If you're backing up everything at once, this means your system’s capacity needs to be double the size of your data.</p>
<p>However, you can plug in an external drive(s) as a staging ground for the backup files. This could even be considered your onsite backup!</p>
<p>I also suggest separating your data into logical sets like "cameras", "media", etc. This allows you to snapshot individual datasets and only be limited to needing enough free space to match your largest dataset.</p>
<h3 id="heading-storage-cloud">Storage (cloud)</h3>
<p>Let's spend as little as possible (of course!). Generally, the cheapest tier is called cold storage and is cheaper because they remove the drives (maybe even tape?) and shelve them until you request the data. The drawback is that you have to wait for your data to become available, which is usually counted in hours. But is that a problem?</p>
<p>Ideally, I will never need to download this backup. And if something catastrophic happens to my NAS, I probably have a day(s) of lead time to wait for the delivery of replacement hardware. Likewise, cloud cold storage tiers would take several hours of lead time for them to plug the drives back in for me to begin downloading. I just need to coordinate those two lead times and the access delay with cold storage becomes a non-issue.</p>
<p>Here's the comparison of the main contenders.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Provider</td><td>Tier</td><td>Storage ($/TB/yr)</td><td>Download ($/TB)</td></tr>
</thead>
<tbody>
<tr>
<td>AWS</td><td>Glacier Deep Archive</td><td>$11.88</td><td>$2.50</td></tr>
<tr>
<td>Azure</td><td>Archive</td><td>$11.88</td><td>$20.00</td></tr>
<tr>
<td>Google Cloud</td><td>Archive</td><td>$14.40</td><td>$50.00</td></tr>
<tr>
<td>Backblaze</td><td>B2</td><td>$60.00</td><td>$10.00</td></tr>
</tbody>
</table>
</div><p>AWS and Azure are both the cheapest for storage, but the download pricing gives AWS the win.</p>
<h4 id="heading-but-you-only-compared-four">"But you only compared four!"</h4>
<p>Yes, I was not exhaustive in my search, but I have checked other providers like IDrive and Carbonite. Their pricing models are more of a flat rate with a storage cap. IDrive for example wants $99.50/yr for 10TB ($9.95/TB/yr) or $199.50/yr for 20TB ($9.98/TB/yr).</p>
<p>This comes out to be cheaper than AWS or Azure if you want to use exactly their limit. But there is no mention of prorated pricing! Needing to pay for 20TB of storage for, say, 10.1TB of data does not sit well with me.</p>
<h2 id="heading-overview">Overview</h2>
<h3 id="heading-self-encryption-1">Self-Encryption</h3>
<p>Let's encrypt the data ourselves. We can use <a target="_blank" href="https://github.com/openssl/openssl">OpenSSL</a> to encrypt it using a command like:</p>
<pre><code class="lang-plaintext">openssl enc -aes-256-cbc -md sha512 -pbkdf2 -iter 250000 -pass pass:my_password
</code></pre>
<h3 id="heading-fault-tolerant-1">Fault-Tolerant</h3>
<p><a target="_blank" href="https://en.wikipedia.org/wiki/Parchive">Par2</a> is a tool to generate parity files. Let's generate them for our data files. It'd use a command like:</p>
<pre><code class="lang-plaintext">par2create -r5 -n1 -m3000 -q my_filename
</code></pre>
<p>Which would generate two files:</p>
<pre><code class="lang-plaintext">my_filename.par2
my_filename.vol000+100.par2
</code></pre>
<h3 id="heading-compatible-1">Compatible</h3>
<p>The backups should be split into 4GiB files. We can simply use the Linux <code>split</code> command:</p>
<pre><code class="lang-plaintext">split -b 4G --suffix-length=6 - my_filename_prefix_
</code></pre>
<p>Which would generate files like:</p>
<pre><code class="lang-plaintext">my_filename_prefix_aaaaaa
my_filename_prefix_aaaaab
my_filename_prefix_aaaaac
...
</code></pre>
<h3 id="heading-uploading">Uploading</h3>
<p>Finally, we need a command to upload the files to AWS, which can look like:</p>
<pre><code class="lang-plaintext">s3 sync --exclude * \
        --include my_filename_prefix_* \
        my_local_directory \
        s3://path/to/incremental/directory \
        --storage-class DEEP_ARCHIVE
</code></pre>
<h2 id="heading-design">Design</h2>
<h3 id="heading-naming-and-file-structure">Naming and file structure</h3>
<p>Let's have sensible names/prefixes to make this work. The date format shall go from broad-to-specific, ie. year, then month, then day. <a target="_blank" href="https://en.wikipedia.org/wiki/ISO_8601">The only order that should exist</a>.</p>
<p>For the ZFS snapshots, I settled on the name format <code>offsite-YYYYMMDD</code>.</p>
<p>The incremental backup would need to specify the snapshot date range that it pertains to, so for a given dataset <code>my_dataset</code>, the prefix format is <code>my_dataset-FYYYYMMDD-TYYYYMMDD-part-</code>. Where <code>F</code> is for "from" and <code>T</code> is for "to".</p>
<p>On AWS, there are two distinct directories to have.</p>
<p>Incremental backups need a baseline backup to increment on:</p>
<p><code>my_aws_bucket/my_dataset/baseline-YYYYMMDD/</code></p>
<p>Then the increments themselves would go into</p>
<p><code>my_aws_bucket/my_dataset/incrementals/FYYYYMMDD-TYYYYMMDD/</code></p>
<p>Altogether, here's a realistic example with actual dates:</p>
<h4 id="heading-zfs-snapshots">ZFS Snapshots</h4>
<pre><code class="lang-plaintext">$ zfs list -t all | grep "cameras.*offsite"
tank1/cameras@offsite-20201123
tank1/cameras@offsite-20220610
tank1/cameras@offsite-20220620
...
tank1/cameras@offsite-20230726
</code></pre>
<h4 id="heading-incremental-backup-files-and-their-parity-files">Incremental backup files and their parity files</h4>
<pre><code class="lang-plaintext">$ ls -alh | grep cameras
-rw-r--r--  1 root root 4.0G Jul 26 15:01 cameras-F20230719-T20230726-part-aaaaaa
-rw-r--r--  1 root root  40K Jul 26 15:02 cameras-F20230719-T20230726-part-aaaaaa.par2
-rw-r--r--  1 root root 206M Jul 26 15:02 cameras-F20230719-T20230726-part-aaaaaa.vol000+100.par2
-rw-r--r--  1 root root 3.9G Jul 26 15:01 cameras-F20230719-T20230726-part-aaaaab
-rw-r--r--  1 root root  40K Jul 26 15:03 cameras-F20230719-T20230726-part-aaaaab.par2
-rw-r--r--  1 root root 199M Jul 26 15:03 cameras-F20230719-T20230726-part-aaaaab.vol000+100.par2
</code></pre>
<h4 id="heading-on-aws-s3">On AWS S3</h4>
<p>Baseline files:</p>
<pre><code class="lang-plaintext">$ s3 ls my_bucket/cameras/baseline-20201123/
2020-11-25 23:25:10          0
2020-11-27 20:09:56 4294967296 cameras-T20201123-part-aaaaaa
2020-11-27 20:09:56      40428 cameras-T20201123-part-aaaaaa.par2
2020-11-27 20:09:56  215037572 cameras-T20201123-part-aaaaaa.vol000+100.par2
2020-11-27 20:09:58 4294967296 cameras-T20201123-part-aaaaab
2020-11-27 20:09:56      40428 cameras-T20201123-part-aaaaab.par2
2020-11-27 20:09:56  215037572 cameras-T20201123-part-aaaaab.vol000+100.par2
2020-11-27 20:09:56 4294967296 cameras-T20201123-part-aaaaac
2020-11-27 20:10:16      40428 cameras-T20201123-part-aaaaac.par2
2020-11-27 20:10:18  215037572 cameras-T20201123-part-aaaaac.vol000+100.par2
...
</code></pre>
<p>Incremental files:</p>
<pre><code class="lang-plaintext">$ s3 ls my_bucket/cameras/incrementals/F20230719-T20230726/
2023-07-26 22:03:55 4294967296 cameras-F20230719-T20230726-part-aaaaaa
2023-07-26 22:03:55      40436 cameras-F20230719-T20230726-part-aaaaaa.par2
2023-07-26 22:03:55  215037628 cameras-F20230719-T20230726-part-aaaaaa.vol000+100.par2
2023-07-26 22:03:55 4152776288 cameras-F20230719-T20230726-part-aaaaab
2023-07-26 22:03:55      40436 cameras-F20230719-T20230726-part-aaaaab.par2
2023-07-26 22:03:55  207928428 cameras-F20230719-T20230726-part-aaaaab.vol000+100.par2
</code></pre>
<h3 id="heading-script-procedure">Script Procedure</h3>
<p>It is a relatively straightforward operation:</p>
<ol>
<li><p>Find the latest backup that is on AWS (date A).</p>
</li>
<li><p>Create a new snapshot (date B).</p>
</li>
<li><p>Export the incremental snapshot from date A to date B.</p>
<ol>
<li><p>Pipe to <code>openssl</code> for encryption.</p>
</li>
<li><p>Pipe to <code>split</code> to become 4GiB files.</p>
</li>
</ol>
</li>
<li><p>Run <code>par2create</code> on each split file.</p>
</li>
<li><p>Upload all of it to AWS.</p>
</li>
</ol>
<h3 id="heading-automating">Automating</h3>
<p>We'll just use <code>crontab</code>. I set mine to run weekly:</p>
<p><code>0 15 * * 3 bash -lc "/path/to/backup.sh"</code></p>
<h3 id="heading-storing-the-encryption-keys">Storing the encryption keys</h3>
<p>We don't want to put our password directly into the command. You need to create a JSON file to contain the passwords and pass it in with the <code>--config</code> flag.</p>
<p>The JSON format is:</p>
<pre><code class="lang-json">{
    <span class="hljs-attr">"my_dataset_A"</span>: {
        <span class="hljs-attr">"pass"</span>: <span class="hljs-string">"my_password"</span>
    },
    <span class="hljs-attr">"my_dataset_B"</span>: {
        <span class="hljs-attr">"pass"</span>: <span class="hljs-string">"my_password2"</span>
    },
}
</code></pre>
<p><strong>WARNING: Put special care into escaping the special characters in your password!</strong></p>
<p>And that's all for now! Visit the <a target="_blank" href="https://github.com/nekorevend/zfs-to-aws">GitHub project</a> to explore the script yourself!</p>
]]></content:encoded></item><item><title><![CDATA[Organizing iPhone Media by their Types]]></title><description><![CDATA[iPhones (and iPads) have limited file management capabilities — we all know this. This affects how well you're able to filter through your media. The most common pain point for me is having no choice but to see screenshots and everything else intersp...]]></description><link>https://victorchang.codes/separating-iphone-media-types</link><guid isPermaLink="true">https://victorchang.codes/separating-iphone-media-types</guid><category><![CDATA[iphone]]></category><category><![CDATA[iOS]]></category><category><![CDATA[organization]]></category><category><![CDATA[media]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Fri, 07 Jul 2023 00:14:24 GMT</pubDate><content:encoded><![CDATA[<p>iPhones (and iPads) have limited file management capabilities <strong>—</strong> we all know this. This affects how well you're able to filter through your media. The most common pain point for me is having no choice but to see screenshots and everything else interspersed with the photos I actually want to see.</p>
<p>To be fair, iOS does provide filtering by type, specifically these types:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1687937961535/a4e95ebe-6bf7-438b-ad70-4bdde9d3d089.png" alt class="image--center mx-auto" /></p>
<p>Notice something missing? Photos! <strong>There is no filter available to only see your photos.</strong> The closest thing you can do is to filter to Live Photos, but that does not include any non-Live photos. Non-Live happens more often than you think, such as for long-exposure night photos and photos you take during videos (or you simply turned off Live).</p>
<p>Photos aren't the only issue with the provided filters. The "Videos" filter includes camera videos <strong>and</strong> screen recordings even though "Screen Recordings" is its own filter. So there is also no way to view only your camera videos without screen recordings mixed in! Let's try to solve all of this.</p>
<p>So how can you filter to only seeing your photos, without the distraction of screenshots, videos, etc. mixed in?</p>
<p>I don't have a solution on the iPhone itself (Apple, please fix this!), but I do export all of my media to a NAS (network-attached storage) and have written a <a target="_blank" href="https://github.com/nekorevend/iPhone-Media-Splitter">tool</a> to help separate them there. Once the tool finishes processing your files, it outputs a simple report like this:</p>
<pre><code class="lang-plaintext">Copying /media/iphone/all_media/IMG_1551.HEIC to /cameras/iPhone 13/IMG_1551.HEIC
Copying /media/iphone/all_media/IMG_1552.HEIC to /cameras/iPhone 13/IMG_1552.HEIC
Copying /media/iphone/all_media/IMG_1553.HEIC to /cameras/iPhone 13/IMG_1553.HEIC
Copying /media/iphone/all_media/IMG_1554.HEIC to /cameras/iPhone 13/IMG_1554.HEIC
Copying /media/iphone/all_media/IMG_1555.HEIC to /cameras/iPhone 13/IMG_1555.HEIC
Copying /media/iphone/all_media/IMG_1551.MOV to /cameras/iPhone 13/IMG_1551.MOV
Copying /media/iphone/all_media/IMG_1547.MOV to /cameras/iPhone 13 Videos/IMG_1547.MOV
Copying /media/iphone/all_media/IMG_1548.MOV to /cameras/iPhone 13 Videos/IMG_1548.MOV
Copying /media/iphone/all_media/IMG_1550.PNG to /media/iphone/screenshots/IMG_1550.PNG
Copying /media/iphone/all_media/IMG_1549.PNG to /media/iphone/screenshots/IMG_1549.PNG
Done!

Handled:
5 new camera photos.
1 new videos from live photos.
2 new camera videos.
2 new screenshots.
</code></pre>
<p><strong>NOTE:</strong> As of 2023-07-06, the tool supports separating Photos/Live Photos, Videos, Screen Recordings, and Screenshots, but it cannot differentiate between your photos and those that were sent to you (via AirDrop or otherwise). Below I detail how this tool works today, and include sections about how I plan to support AirDrop in the future.</p>
<h1 id="heading-how-does-it-work">How does it work?</h1>
<p>With the help of <a target="_blank" href="https://pypi.org/project/pyexiftool/">ExifTool</a> and <a target="_blank" href="https://pypi.org/project/pymediainfo/">MediaInfo</a>, the script can find hints in the metadata to make educated guesses about the type of each file. Below I will outline my logic for each type.</p>
<h2 id="heading-is-it-an-iphone-photo">Is it an iPhone photo?</h2>
<p>Not every JPEG or HEIC file is a photo taken by your device <strong>—</strong> it could have just been a funny meme you downloaded or a drawing you saved. It could have also been AirDropped to you.</p>
<p>Let's first address how to determine if a photo was taken by the iPhone's stock camera app. <em>Note: I am considering third-party camera apps as out-of-scope.</em></p>
<p>All photos from the camera app have metadata and provide two key data points:</p>
<ol>
<li><p><strong>The maker of the lens.</strong> In this case, we want <code>EXIF:LensMake</code> or <code>EXIF:Make</code> to say "Apple".</p>
</li>
<li><p><strong>The model of the lens.</strong> In this case, we want <code>EXIF:LensModel</code> or <code>EXIF:Model</code> to include "iPhone" or "iPad".</p>
</li>
</ol>
<p>Easy. Any picture that lacks this metadata will not be included.</p>
<h3 id="heading-what-about-photos-from-airdrop">What about photos from AirDrop?</h3>
<p>This gets complicated. iOS <a target="_blank" href="https://discussions.apple.com/thread/7978914">does not provide any mechanism to distinguish between your own photos and photos from others</a>. There is metadata about the model of iPhone that took the photo, but it's too easy for a friend to have the same model. That said, there are two methodologies I found and will leverage both for the tool.</p>
<h4 id="heading-1-boot-time">1) Boot Time</h4>
<p>There is a combination of fields that can help us identify a specific device:</p>
<ol>
<li><p><code>Composite:RunTimeSincePowerUp</code> contains the device's uptime in seconds.</p>
</li>
<li><p><code>EXIF:DateTimeOriginal</code> gives the time the photo was taken.</p>
</li>
<li><p><code>EXIF:OffsetTimeOriginal</code> defines the timezone for the given time.</p>
</li>
</ol>
<p>Not sure why Apple opted to include the device's uptime in each photo taken, but we'll use it! With the uptime and date info available, we can calculate when the device last booted up. We can then group up the photos by their shared boot times.</p>
<p>... Just don't turn your phone on at the exact same second as a friend! But even if you do, it shouldn't matter:</p>
<h4 id="heading-2-filename-numbers">2) Filename Numbers</h4>
<p>iOS increments a single counter when numbering all types. Here's an example list:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Filename</td><td>Type</td></tr>
</thead>
<tbody>
<tr>
<td>IMG_1592.PNG</td><td>screenshot</td></tr>
<tr>
<td>IMG_1593.PNG</td><td>screenshot</td></tr>
<tr>
<td>IMG_1594.MOV</td><td>video</td></tr>
<tr>
<td>IMG_1595.HEIC</td><td>live photo</td></tr>
<tr>
<td>IMG_1595.MOV</td><td>live photo's video</td></tr>
<tr>
<td>IMG_1596.MOV</td><td>video</td></tr>
<tr>
<td>IMG_1597.PNG</td><td>screenshot</td></tr>
<tr>
<td>IMG_1598.HEIC</td><td>live photo</td></tr>
<tr>
<td>IMG_1598.MOV</td><td>live photo's video</td></tr>
</tbody>
</table>
</div><p>If we depend on iOS's file-naming behavior (and all files have their original filenames), and combine that knowledge with the Date Taken/Created, we can pretty accurately identify one device from another even if they have overlapping file numbers.</p>
<p>Unfortunately, there needs to be some interaction to make this work. The tool can use the two methodologies to group up files to their respective devices, but it is unable to identify which group is <em>your</em> device.</p>
<p>We could make assumptions like "the device with the most photos must be the owner's", but it's possible that a peer is a far more prolific photographer. Or maybe the device with the most screenshots would be the owner's since it's unlikely that people are AirDropping screenshots... but is that always going to be true? Some owners might not take screenshots at all!</p>
<p>I don't think there are any 100% reliable assumptions we can make, so the best course of action is to prompt the user to check on a photo(s) from each group and answer whether that photo is their own.</p>
<h2 id="heading-is-it-an-iphone-video">Is it an iPhone video?</h2>
<p>Just like with photos, not every MOV file is a video taken by your device. And here's the kicker: the video portion of Live Photos is not embedded into the image file (like is the case on some Android phones) and ends up saved as a separate MOV file. I am in the school of thought that these "Live Photo videos" should be kept alongside their corresponding photos and, therefore, should be treated differently from regular video recordings.</p>
<p>Fortunately, with the help of MediaInfo, there are some metadata clues we can use:</p>
<ol>
<li><p>Video recordings have a <code>comapplequicktimemake</code> field that says "Apple".</p>
</li>
<li><p>Live Photo videos have a <code>livephoto</code> field.</p>
</li>
</ol>
<h3 id="heading-airdrop">AirDrop?</h3>
<p>While photos have a field to help group by boot time, videos don't have that data. But the filename methodology is still viable.</p>
<h2 id="heading-is-it-a-screenshot">Is it a screenshot?</h2>
<p>We can use ExifTool to parse two fields:</p>
<ol>
<li><p>The <code>ICC_Profile:DeviceManufacturer</code> or <code>ICC_Profile:PrimaryPlatform</code> field should say "APPL".</p>
</li>
<li><p>The <code>EXIF:UserComment</code> field should say "Screenshot".</p>
</li>
</ol>
<h3 id="heading-airdrop-1">AirDrop?</h3>
<p>Same with videos, we are limited to using the filename methodology.</p>
<h2 id="heading-is-it-a-screen-recording">Is it a screen recording?</h2>
<p>For whatever reason, these files are MP4 instead of MOV, so that is already one differentiator. And of course, to differentiate from any other MP4, we can use metadata.</p>
<p>The <code>performer</code> field should say "ReplayKitRecording".</p>
<h3 id="heading-airdrop-2">AirDrop?</h3>
<p>We are limited to the filename methodology.</p>
<h1 id="heading-in-an-ideal-world-we-shouldnt-need-this-tool">In an ideal world, we shouldn't need this tool.</h1>
<p>I just detailed a lot of thought and effort that went into working around a limitation of iOS. I want to make it clear that I wish I didn't need to do this at all. Credit where credit is due, we must compare with the competition. Android allows you to navigate the folder structure and avoids the problem:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>What</td><td>Where</td></tr>
</thead>
<tbody>
<tr>
<td>Camera photos (including Live)</td><td>Image files in <code>DCIM/Camera</code></td></tr>
<tr>
<td>Live photo videos</td><td>Embedded into the image files in <code>DCIM/Camera</code></td></tr>
<tr>
<td>Camera videos</td><td>Video files in <code>DCIM/Camera</code></td></tr>
<tr>
<td>Screenshots</td><td><code>Pictures/Screenshots</code></td></tr>
<tr>
<td>Screen recordings</td><td>Files that start with "screen" in <code>Movies</code></td></tr>
<tr>
<td>Anything received from others</td><td><code>Download</code></td></tr>
</tbody>
</table>
</div><p>A human can navigate to what they want on their own without the need for some tool(s) people put online. Isn't that better?</p>
<p>As things are now though, enjoy my tool! Hope you find it as useful as I do.</p>
<h1 id="heading-to-be-continued">To be continued...</h1>
<p>As mentioned before, I still need to design and implement support for differentiating between your photos and those shared to you. I will save that for a followup post.</p>
]]></content:encoded></item><item><title><![CDATA["Segmented Linear" Calibration for ESPHome]]></title><description><![CDATA[UPDATE (2023-08-19)
This article is no longer necessary as of Mat931's PR!
Now you can have the behavior of what I called "segmented linear" by specifying the "exact" method:
filters:
  - calibrate_linear:
      method: exact
      datapoints:
      ...]]></description><link>https://victorchang.codes/segmented-linear-calibration-for-esphome</link><guid isPermaLink="true">https://victorchang.codes/segmented-linear-calibration-for-esphome</guid><category><![CDATA[calibration]]></category><category><![CDATA[sensors]]></category><category><![CDATA[esphome]]></category><dc:creator><![CDATA[Victor Chang]]></dc:creator><pubDate>Sun, 14 May 2023 21:57:27 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-update-2023-08-19">UPDATE (2023-08-19)</h2>
<p>This article is no longer necessary as of <a target="_blank" href="https://github.com/esphome/esphome/pull/5040">Mat931's PR</a>!</p>
<p>Now you can have the behavior of what I called "segmented linear" by specifying the "exact" method:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">filters:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">calibrate_linear:</span>
      <span class="hljs-attr">method:</span> <span class="hljs-string">exact</span>
      <span class="hljs-attr">datapoints:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">10</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">12</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">55</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">50</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">100</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">105</span>
</code></pre>
<p>Horray!</p>
<p>The original article below is kept for posterity.</p>
<h2 id="heading-original">ORIGINAL</h2>
<p>ESPHome <a target="_blank" href="https://esphome.io/components/sensor/index.html#offset">provides a few functions</a> to help calibrate the measurements being reported by the sensors you set up. But I am finding them to be inadequate.</p>
<h2 id="heading-inadequate-how">Inadequate how?</h2>
<p>The two most relevant functions are <code>calibrate_linear</code> and <code>calibrate_polynomial</code>.</p>
<p><code>calibrate_linear</code> is pretty straightforward. You give it at least two pairs of numbers and it generates a best-fit straight line for what adjustment it should apply. Let's use an example:</p>
<p>If a watt meter is reporting 10W when it should be 12W, and 100W when it's supposed to be 105W, then you can use this syntax to adjust for the difference:</p>
<pre><code class="lang-yaml">    <span class="hljs-attr">filters:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">calibrate_linear:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">10</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">12</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">100</span> <span class="hljs-string">-&gt;</span> <span class="hljs-number">105</span>
</code></pre>
<p>But what if the sensor's inaccuracy isn't a linear relationship? In the above example, the sensor is consistently measuring a bit too low. But what if it reported 55W when it should be 50W? That relationship is no longer linear, and the output of this calibration will be incorrect at all known points. There was a <a target="_blank" href="https://github.com/esphome/issues/issues/2768">bug filed</a> for this but was closed... but not thanks to a fix. They simply closed the bug after updating the documentation to say what's wrong.</p>
<p><code>calibrate_polynomial</code> isn't necessarily suitable either. There is no guarantee that the polynomial equation would be a perfect fit for the known data points. It would be more correct than a linear calibration, but I think that the least a calibration can do is to <strong>exactly match the known points</strong>.</p>
<p>As stated in the bug, a "piecewise linear fit" would be preferred, and I agree. At least, I agree with my interpretation of it: do <code>calibrate_linear</code>'s behavior in between each pair of data points.</p>
<h2 id="heading-lets-implement-that">Let's implement that!</h2>
<p>ESPHome uses a Python-to-C++ mix of code that I don't have the time to get into right now. In the interest of expedience for my own needs, I am just writing a pure C++ function. I also foresee a point of ambiguity that will create some <a target="_blank" href="https://en.wiktionary.org/wiki/bikeshedding">bikeshedding</a> when I do eventually create a PR for ESPHome. The ambiguity is this:</p>
<h3 id="heading-how-to-handle-values-outside-your-known-measurements">How to handle values outside your known measurements?</h3>
<p>Say I have data points at 12, 50, and 105 watts. What should be done for values outside of that range? Like say, 5 or 500 watts.</p>
<p>I think it's fine to take the nearest pair and extend out its linear slope to handle this. But if you have several points, there can appear to be a non-linear relationship. To better match that, it can be valid to say that you want to use the nearest <em>n</em> pairs and get an average slope from that (much like how <code>calibrate_linear</code> works right now), or even switch to <code>calibrate_polynomial</code>.</p>
<p>Let's just implement the simplest one: extending the linear slope of the nearest pair.</p>
<pre><code class="lang-cpp"><span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">calibrate_segmented_linear</span><span class="hljs-params">(<span class="hljs-built_in">std</span>::<span class="hljs-built_in">vector</span>&lt;<span class="hljs-built_in">std</span>::<span class="hljs-built_in">vector</span>&lt;<span class="hljs-keyword">float</span>&gt;&gt; mapping, <span class="hljs-keyword">float</span> x)</span> </span>{
    <span class="hljs-keyword">float</span> res = x;
    <span class="hljs-keyword">if</span> (x &lt; mapping[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>]) {
        <span class="hljs-comment">// Less than mapping</span>
        <span class="hljs-comment">// Use the left-most pair.</span>
        <span class="hljs-keyword">float</span> before_a = mapping[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>];
        <span class="hljs-keyword">float</span> after_a = mapping[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>];
        <span class="hljs-keyword">float</span> before_b = mapping[<span class="hljs-number">1</span>][<span class="hljs-number">0</span>];
        <span class="hljs-keyword">float</span> after_b = mapping[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>];
        <span class="hljs-keyword">float</span> before_diff = before_b - before_a;
        <span class="hljs-keyword">float</span> after_diff = after_b - after_a;
        <span class="hljs-keyword">float</span> diff = before_a - x;
        <span class="hljs-keyword">float</span> ratio = diff / before_diff;
        res = after_a - (ratio * after_diff);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (x &gt; mapping[mapping.size() - <span class="hljs-number">1</span>][<span class="hljs-number">0</span>]) {
        <span class="hljs-comment">// More than mapping</span>
        <span class="hljs-comment">// Use the right-most pair.</span>
        <span class="hljs-keyword">int</span> i = mapping.size() - <span class="hljs-number">1</span>;
        <span class="hljs-keyword">float</span> before_a = mapping[i<span class="hljs-number">-1</span>][<span class="hljs-number">0</span>];
        <span class="hljs-keyword">float</span> after_a = mapping[i<span class="hljs-number">-1</span>][<span class="hljs-number">1</span>];
        <span class="hljs-keyword">float</span> before_b = mapping[i][<span class="hljs-number">0</span>];
        <span class="hljs-keyword">float</span> after_b = mapping[i][<span class="hljs-number">1</span>];
        <span class="hljs-keyword">float</span> before_diff = before_b - before_a;
        <span class="hljs-keyword">float</span> after_diff = after_b - after_a;
        <span class="hljs-keyword">float</span> diff = x - before_b;
        <span class="hljs-keyword">float</span> ratio = diff / before_diff;
        res = after_b + (ratio * after_diff);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Within mapping</span>
        <span class="hljs-comment">// Find and use the pair that x sits between.</span>
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">1</span>; i &lt; mapping.size(); i++) {
            <span class="hljs-keyword">float</span> before_a = mapping[i<span class="hljs-number">-1</span>][<span class="hljs-number">0</span>];
            <span class="hljs-keyword">float</span> after_a = mapping[i<span class="hljs-number">-1</span>][<span class="hljs-number">1</span>];
            <span class="hljs-keyword">float</span> before_b = mapping[i][<span class="hljs-number">0</span>];
            <span class="hljs-keyword">float</span> after_b = mapping[i][<span class="hljs-number">1</span>];
            <span class="hljs-keyword">if</span> (x &lt;= before_b) {
                <span class="hljs-keyword">float</span> before_diff = before_b - before_a;
                <span class="hljs-keyword">float</span> after_diff = after_b - after_a;
                <span class="hljs-keyword">float</span> diff = x - before_a;
                <span class="hljs-keyword">float</span> ratio = diff / before_diff;
                res = after_a + (ratio * after_diff);
                <span class="hljs-keyword">break</span>;
            }
        }
    }
    <span class="hljs-keyword">return</span> res;
}
</code></pre>
<p>That's it! Just create a header file like <code>calibration.h</code> in ESPHome and import it like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">esphome:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">my_device</span>
  <span class="hljs-attr">includes:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">calibration.h</span>
</code></pre>
<p>Which can be used in a filter like so:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">sensor:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">platform:</span> <span class="hljs-string">a_supported_watt_meter</span>
    <span class="hljs-attr">update_interval:</span> <span class="hljs-string">1s</span>
    <span class="hljs-attr">power:</span>
      <span class="hljs-attr">name:</span> <span class="hljs-string">"My Power"</span>
      <span class="hljs-attr">accuracy_decimals:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">filters:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
            static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
                {10.0, 12.0},
                {55.0, 50.0},
                {100.0, 105.0},
            };
            return calibrate_segmented_linear(mapping, x);</span>
</code></pre>
<p>This is not the cleanest code, but it is functional. Would be nice to still be able to use the <code>- 10.0 -&gt; 12.0</code> syntax, but it gets the job done.</p>
<p>Optimizations can be made such as doing a binary search when <code>x</code> is within the <code>mapping</code> values, but the longest mapping list I have is only 11 items. I don't think binary search is worthwhile when <code>n</code> is so small.</p>
<h2 id="heading-a-real-world-use-case">A real-world use-case</h2>
<p>This calculation method happens to be the way that the <a target="_blank" href="https://www.epa.gov/outdoor-air-quality-data/how-aqi-calculated">EPA defines their AQI</a>. AQI is calculated based on the measured µg/m³ weight concentration and we can use <code>calibrate_segmented_linear</code> to calculate PM2.5 AQI like so:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">lambda:</span> <span class="hljs-string">|-
    static std::vector&lt;std::vector&lt;float&gt;&gt; mapping = {
        {0, 0},
        {12.1, 50},
        {35.5, 100},
        {55.5, 150},
        {150.5, 200},
        {250.5, 300},
        {350.5, 400},
        {500.5, 500},
    };
    return calibrate_segmented_linear(mapping, id(pm_2_5).state);</span>
</code></pre>
<p>Now that's a whole lot cleaner than <a target="_blank" href="https://community.home-assistant.io/t/aqi-from-pms7003-pm2-5-sensor/336598">this manual if-else logic</a>!</p>
]]></content:encoded></item></channel></rss>