
<?xml-stylesheet type="text/xsl" href="https://wip.tf/style.xsl"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Wɪᴘ In Progress…</title>
    <link>https://wip.tf/posts/</link>
    <description>A collection of things I tinker with during my free time.</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <lastBuildDate>Thu, 05 Mar 2026 21:11:11 -0500</lastBuildDate>
    
      <atom:link href="https://wip.tf/posts/index.xml" rel="self" type="application/rss+xml" />
    
    
      <item>
        <title>TGIF-Claude: Visualizing Claude Pro usage relative to the week</title>
        <link>https://wip.tf/posts/tgif-claude-visualizing-claude-pro-usage-relative-to-the-week/</link>
        <pubDate>Thu, 05 Mar 2026 21:11:11 -0500</pubDate>
        <guid>https://wip.tf/posts/tgif-claude-visualizing-claude-pro-usage-relative-to-the-week/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/tgif-claude-visualizing-claude-pro-usage-relative-to-the-week.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;Earlier this week, my yearly &lt;a href=&#34;https://support.claude.com/en/articles/11049762-choosing-a-claude-plan&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;Claude Pro&lt;/a&gt; subscription auto-renewed. This seems to have triggered a &amp;ldquo;service improvement&amp;rdquo;, making me subject to &lt;a href=&#34;https://news.ycombinator.com/item?id=44713754&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;weekly limits&lt;/a&gt;. I had heard of these, but had until then only been subject to the 5h session limits.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/tgif-claude-visualizing-claude-pro-usage-relative-to-the-week.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>Earlier this week, my yearly <a href="https://support.claude.com/en/articles/11049762-choosing-a-claude-plan" target="_blank" rel="noopener noreferrer">Claude Pro</a> subscription auto-renewed. This seems to have triggered a &ldquo;service improvement&rdquo;, making me subject to <a href="https://news.ycombinator.com/item?id=44713754" target="_blank" rel="noopener noreferrer">weekly limits</a>. I had heard of these, but had until then only been subject to the 5h session limits.</p>
<p>With this came a new form of anxiety summarized as &ldquo;OMG I think I might be ahead in my consumption of the weekly limit, compared to the passage of time.&rdquo;</p>
<p>To fight this new existential dread and live with facts not fear, I overlay the week&rsquo;s time elapsed against the token usage progress, directly on the <a href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer">Claude usage page</a>.</p>
<p><img src="images/tgif-claude.png#center" alt="Claude usage page with week time elapsed overlaid against token consumption" title="tgif-claude overlay"></p>
<p>I use a bookmarklet for this: <a href="https://github.com/nbr23/tgif-claude/" target="_blank" rel="noopener noreferrer">tgif-claude</a>. It auto-refreshes, but obviously requires to be clicked on when the page is loaded (a <a href="https://en.wikipedia.org/wiki/Userscript_manager" target="_blank" rel="noopener noreferrer">userscript manager</a> or full-on browser add-on seemed overkill).</p>
<p>Copy it from here:</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">bookmarklet.js</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://github.com/nbr23/tgif-claude/blob/main/bookmarklet.js" target="_blank" rel="noopener noreferrer">view code</a><button class="github-embed-copy" title="Copy to clipboard"
                      onclick="var b=this,p=b.closest('.github-embed').querySelector('pre');navigator.clipboard.writeText(p.textContent).then(function(){b.style.opacity='1';setTimeout(function(){b.style.opacity=''},1500)})">
                      <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round">
                        <rect x="4.5" y="4.5" width="7.5" height="7.5" rx="1"/>
                        <path d="M8.5 4.5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v5.5a1 1 0 0 0 1 1h2.5"/>
                      </svg>
                    </button></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">javascript</span><span style="color:#f92672">:</span>(<span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>()<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B</span><span style="color:#e6db74">&#39;tgif-claude-marker&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;tgif-claude-label&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span>.<span style="color:#a6e22e">forEach</span>(<span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">id</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">el</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#a6e22e">id</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">el</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">el</span>.<span style="color:#a6e22e">remove</span>()<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">resetEl</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Array.<span style="color:#a6e22e">from</span>(document.<span style="color:#a6e22e">querySelectorAll</span>(<span style="color:#e6db74">&#39;p&#39;</span>)).<span style="color:#a6e22e">find</span>(<span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">p</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">return</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">EResets</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cw</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B3</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cd</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B1</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C2</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cd</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B2</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">BAP</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">DM</span><span style="color:#f92672">%</span><span style="color:#ae81ff">24</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">p</span>.<span style="color:#a6e22e">textContent</span>.<span style="color:#a6e22e">trim</span>())<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#f92672">!</span><span style="color:#a6e22e">resetEl</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">alert</span>(<span style="color:#e6db74">&#39;tgif-claude%3A%20weekly%20reset%20text%20not%20found&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">return</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">row</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">resetEl</span>.<span style="color:#a6e22e">parentElement</span>.<span style="color:#a6e22e">parentElement</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">barContainer</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">row</span>.<span style="color:#a6e22e">querySelector</span>(<span style="color:#e6db74">&#39;.bg-bg-000&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">fillEl</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">barContainer</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">barContainer</span>.<span style="color:#a6e22e">querySelector</span>(<span style="color:#e6db74">&#39;.bg-accent-secondary-200&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#f92672">!</span><span style="color:#a6e22e">fillEl</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">alert</span>(<span style="color:#e6db74">&#39;tgif-claude%3A%20progress%20bar%20elements%20not%20found&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">return</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">lastUpdatedEl</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Array.<span style="color:#a6e22e">from</span>(document.<span style="color:#a6e22e">querySelectorAll</span>(<span style="color:#e6db74">&#39;p&#39;</span>)).<span style="color:#a6e22e">find</span>(<span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">p</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">return</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">p</span>.<span style="color:#a6e22e">textContent</span>.<span style="color:#a6e22e">trim</span>().<span style="color:#a6e22e">startsWith</span>(<span style="color:#e6db74">&#39;Last%20updated%3A&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">refreshBtn</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>document.<span style="color:#a6e22e">querySelector</span>(<span style="color:#e6db74">&#39;button%5Baria-label%3D%22Refresh%20usage%20limits%22%5D&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">m</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">resetEl</span>.<span style="color:#a6e22e">textContent</span>.<span style="color:#a6e22e">trim</span>().<span style="color:#a6e22e">match</span>(<span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">FResets</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cw</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cd</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span>(<span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Cd</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">BAP</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">DM</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">DAY_MAP</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Sun</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Mon</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">201</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Tue</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">202</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Wed</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">203</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Thu</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">204</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Fri</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">205</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">Sat</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">206</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">targetDay</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">DAY_MAP</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">Bm</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B1</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>parseInt(<span style="color:#a6e22e">m</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B2</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2010</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">minute</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>parseInt(<span style="color:#a6e22e">m</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B3</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2010</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">m</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B4</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;PM&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">!%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2012</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2012</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">m</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B4</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;AM&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2012</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">WEEK_MS</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">207</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2024</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">barContainer</span>.<span style="color:#a6e22e">style</span>.<span style="color:#a6e22e">position</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;relative&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">marker</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>document.<span style="color:#a6e22e">createElement</span>(<span style="color:#e6db74">&#39;div&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">marker</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;tgif-claude-marker&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">marker</span>.<span style="color:#a6e22e">style</span>.<span style="color:#a6e22e">cssText</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;position%3Aabsolute%3Btop%3A0%3Bheight%3A100%25%3Bwidth%3A3px%3B&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;background%3A%23f97316%3Bpointer-events%3Anone%3Bborder-radius%3A1px%3B&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">barContainer</span>.<span style="color:#a6e22e">appendChild</span>(<span style="color:#a6e22e">marker</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>document.<span style="color:#a6e22e">createElement</span>(<span style="color:#e6db74">&#39;div&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;tgif-claude-label&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span>.<span style="color:#a6e22e">className</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">resetEl</span>.<span style="color:#a6e22e">className</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span>.<span style="color:#a6e22e">style</span>.<span style="color:#a6e22e">marginTop</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;4px&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">row</span>.<span style="color:#a6e22e">insertAdjacentElement</span>(<span style="color:#e6db74">&#39;afterend&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">update</span>(<span style="color:#a6e22e">reason</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">usagePct</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>parseFloat(<span style="color:#a6e22e">fillEl</span>.<span style="color:#a6e22e">style</span>.<span style="color:#a6e22e">width</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">now</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">new</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Date()<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">reset</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">new</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Date(<span style="color:#a6e22e">now</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">reset</span>.<span style="color:#a6e22e">setHours</span>(<span style="color:#a6e22e">hour</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">minute</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">daysUntil</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">targetDay</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">-%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">now</span>.<span style="color:#a6e22e">getDay</span>()<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">207</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">25</span><span style="color:#f92672">%</span><span style="color:#ae81ff">207</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">daysUntil</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">26</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">reset</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">now</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">daysUntil</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">207</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">reset</span>.<span style="color:#a6e22e">setDate</span>(<span style="color:#a6e22e">reset</span>.<span style="color:#a6e22e">getDate</span>()<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">daysUntil</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">elapsed</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">WEEK_MS</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">-%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">reset</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">-%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">now</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">timeElapsedPct</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Math.<span style="color:#a6e22e">max</span>(<span style="color:#ae81ff">0</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Math.<span style="color:#a6e22e">min</span>(<span style="color:#ae81ff">100</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">WEEK_MS</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">20100</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">msLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">reset</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">-%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">now</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">daysLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Math.<span style="color:#a6e22e">floor</span>(<span style="color:#a6e22e">msLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#ae81ff">24</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">hoursLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Math.<span style="color:#a6e22e">floor</span>((<span style="color:#a6e22e">msLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">25</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#ae81ff">24</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#ae81ff">60</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">minsLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>Math.<span style="color:#a6e22e">floor</span>((<span style="color:#a6e22e">msLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">25</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#ae81ff">60</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">2060</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#ae81ff">60</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">*%</span><span style="color:#ae81ff">201000</span>))<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingParts</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">5</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">daysLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingParts</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">daysLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;d&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">hoursLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">daysLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingParts</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">hoursLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;h&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingParts</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">minsLeft</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;m&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingStr</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingParts</span>.<span style="color:#a6e22e">join</span>(<span style="color:#e6db74">&#39;%20&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">delta</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">usagePct</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">-%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">timeElapsedPct</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">overPace</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">delta</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">statusColor</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">overPace</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%23dc2626&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%2316a34a&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">statusText</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">overPace</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;over%20pace&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;under%20pace&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;%5Btgif-claude%5D%20usage%3D&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">usagePct</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25%20elapsed%3D&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">timeElapsedPct</span>.<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25%20delta%3D&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">delta</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%2B&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">delta</span>.<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">marker</span>.<span style="color:#a6e22e">style</span>.<span style="color:#a6e22e">left</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">timeElapsedPct</span>.<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">2</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">label</span>.<span style="color:#a6e22e">innerHTML</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;Time%20elapsed%3A%20%3Cb%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">timeElapsedPct</span>.<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25%3C%2Fb%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%20%7C%20Resets%20in%3A%20%3Cb%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">remainingStr</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%3C%2Fb%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%20%7C%20Delta%3A%20%3Cb%20style%3D%22color%3A&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">statusColor</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%22%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">delta</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">E</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">200</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">F</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%2B&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">delta</span>.<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;%25%20(&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">statusText</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#e6db74">&#39;)%3C%2Fb%3E&#39;</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">update</span>(<span style="color:#e6db74">&#39;init&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">if</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>(<span style="color:#a6e22e">lastUpdatedEl</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">var</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">observer</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">new</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">MutationObserver</span>(<span style="color:#66d9ef">function</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span>()<span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">update</span>(<span style="color:#e6db74">&#39;lastUpdated%20mutation&#39;</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">observer</span>.<span style="color:#a6e22e">observe</span>(<span style="color:#a6e22e">lastUpdatedEl</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">childList</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">true</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">subtree</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">true</span><span style="color:#f92672">%</span><span style="color:#ae81ff">2</span><span style="color:#a6e22e">C</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#a6e22e">characterData</span><span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">A</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#66d9ef">true</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span><span style="color:#f92672">%</span><span style="color:#ae81ff">20</span><span style="color:#f92672">%</span><span style="color:#ae81ff">7</span><span style="color:#a6e22e">D</span>)()<span style="color:#f92672">%</span><span style="color:#ae81ff">3</span><span style="color:#a6e22e">B</span>
</span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>Or check the readable code on <a href="https://github.com/nbr23/tgif-claude/blob/main/index.js" target="_blank" rel="noopener noreferrer">github</a>.</p>

]]></content>
      </item>
    
      <item>
        <title>Badging in at the gym with my Garmin watch</title>
        <link>https://wip.tf/posts/badging-gym-garmin-watch/</link>
        <pubDate>Mon, 16 Feb 2026 06:45:47 -0500</pubDate>
        <guid>https://wip.tf/posts/badging-gym-garmin-watch/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/badging-gym-garmin-watch.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;The gym I go to requires you to scan a small key tag with a barcode, to check in. I don&amp;rsquo;t like bulky keychains, so I keep taking it off and tend to forget it when I need it.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/badging-gym-garmin-watch.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>The gym I go to requires you to scan a small key tag with a barcode, to check in. I don&rsquo;t like bulky keychains, so I keep taking it off and tend to forget it when I need it.</p>
<p>To solve this very important and relatable issue, I built <a href="https://github.com/nbr23/garmin-gymcode" target="_blank" rel="noopener noreferrer">gymcode</a>, which allows me to display that barcode on my watch.</p>
<p><img src="images/badging.gif#center" alt="" title="badging in"></p>
<p>Gymcode is a Garmin <a href="https://developer.garmin.com/connect-iq/connect-iq-basics/app-types/#widgets" target="_blank" rel="noopener noreferrer">widget</a>, written in <a href="https://developer.garmin.com/connect-iq/monkey-c/" target="_blank" rel="noopener noreferrer">Monkey C</a>. It can be accessed from the &ldquo;at a glance&rdquo; menu of the watch by pressing the down button from the watch face, making it quick to pull up at the gym&rsquo;s entrance.</p>
<p>The app is not distributed through the Connect IQ store. The membership barcode value is hardcoded in <a href="https://github.com/nbr23/garmin-gymcode/blob/master/source/GymCodeView.mc#L5" target="_blank" rel="noopener noreferrer">GymCodeView.mc</a> and needs to be set before building. The resulting binary must be manually uploaded to the watch as described in the <a href="https://github.com/nbr23/garmin-gymcode?tab=readme-ov-file#setup" target="_blank" rel="noopener noreferrer">README</a>.</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">GymCodeView.mc</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://github.com/nbr23/garmin-gymcode/blob/master/source/GymCodeView.mc#L4-L5" target="_blank" rel="noopener noreferrer">view code (lines 4-5)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-mc" data-lang="mc"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GymCodeView</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">WatchUi.View</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">const</span> MEMBERSHIP_ID <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;&#34;</span>; <span style="color:#f92672">//</span> FIXME SET YOUR MEMBERSHIP ID HERE</span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>I have the fenix 7 pro, which has a fairly large screen. Barcodes are one-dimensional, so height doesn&rsquo;t matter too much (except to give me &ldquo;reading surface&rdquo;). I was able to use nearly the full watch screen, despite its roundedness, to make it as readable as possible by the scanner.</p>
<p>The fenix 7 pro has a decent backlight, and I have had no issues getting the code scanned properly at my local gym.</p>
<p>YMMV depending on watch model, barcode type (gymcode&rsquo;s current implementation supports <a href="https://en.wikipedia.org/wiki/Code_39" target="_blank" rel="noopener noreferrer">Code 39</a>) and your gym&rsquo;s barcode scanner.</p>

]]></content>
      </item>
    
      <item>
        <title>Controlling an air-gapped robot vacuum from Home Assistant using synthesized speech</title>
        <link>https://wip.tf/posts/controlling-an-air-gapped-robot-vacuum-from-home-assistant-using-synthesized-speech/</link>
        <pubDate>Mon, 26 Jan 2026 22:20:22 -0500</pubDate>
        <guid>https://wip.tf/posts/controlling-an-air-gapped-robot-vacuum-from-home-assistant-using-synthesized-speech/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/controlling-an-air-gapped-robot-vacuum-from-home-assistant-using-synthesized-speech.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;I have a &lt;a href=&#34;https://www.dreametech.com/products/l40ultra-robot-vacuum&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;Dreame L40 Ultra&lt;/a&gt; to vacuum and mop around my home.&lt;/p&gt;
&lt;p&gt;The Dreame app requires the robot to have internet access to receive commands, send status information, or modify scheduling. Not being a fan of audio-video recording devices roaming freely in my home, I keep it offline. Unfortunately, this means no Home Assistant integration possibilities, and painful schedule changes (log in to firewall, temporarily allow traffic, open the app, update schedule, block again&amp;hellip;).&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/controlling-an-air-gapped-robot-vacuum-from-home-assistant-using-synthesized-speech.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>I have a <a href="https://www.dreametech.com/products/l40ultra-robot-vacuum" target="_blank" rel="noopener noreferrer">Dreame L40 Ultra</a> to vacuum and mop around my home.</p>
<p>The Dreame app requires the robot to have internet access to receive commands, send status information, or modify scheduling. Not being a fan of audio-video recording devices roaming freely in my home, I keep it offline. Unfortunately, this means no Home Assistant integration possibilities, and painful schedule changes (log in to firewall, temporarily allow traffic, open the app, update schedule, block again&hellip;).</p>
<p>However, this robot (and many others) recognizes a set of voice commands (a feature that works fully offline), so I built <a href="https://github.com/nbr23/jacadi" target="_blank" rel="noopener noreferrer">jacadi</a>, a Go HTTP server that maps endpoints to audio file playback, to play these voice commands on demand.</p>
<p>Note: I am aware of <a href="https://valetudo.cloud/" target="_blank" rel="noopener noreferrer">Valetudo</a>, which solves this problem and more. However, Valetudo requires rooting the robot, losing OTA firmware updates, potentially voiding the warranty, and accepting that not all features are implemented. For now, I wanted a quick zero-modification approach that leaves the robot&rsquo;s software and hardware untouched.</p>
<h2 id="overview">Overview</h2>
<p><img src="images/diagram.svg" alt="System diagram"></p>
<p>Home-assistant performs a <code>POST</code> request to jacadi. Jacadi plays the wake word + the command using aplay on the USB speaker connected to the raspberry pi. The Dreame robot hears the wake word followed by the command, and performs the action.</p>
<figure class="center">
    <video height="500" controls>
        <source src="images/jacadi-clean.mp4" type="video/mp4">
    </video><figcaption class="center"><a href="https://github.com/nbr23/jacadi/blob/master/Dockerfile#L5" target="_blank" rel="noopener noreferrer">en_US-amy-low</a> bossing the vacuum cleaner around</figcaption>
</figure> 
<h2 id="setup">Setup</h2>
<h3 id="hardware">Hardware</h3>
<p>I connected a <a href="https://www.amazon.com/dp/B08QRYTPGH" target="_blank" rel="noopener noreferrer">USB speaker</a> to a raspberry pi located near the robot&rsquo;s homebase.</p>
<h3 id="software">Software</h3>
<p>On the raspberry pi, we need to install <code>alsa-utils</code>, and add the user the container will be running as to the audio group.</p>
<p>With the USB speaker plugged into the raspberry pi, <code>aplay -l</code> will help us figure out which card to use (card 1 here):</p>
<p><img src="images/aplay-l.png" alt="aplay -l" title="Card 1 is our USB Speaker"></p>
<p>A simple test will confirm we have this right:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>speaker-test -D plughw:1,0 -t wav
</span></span></code></pre></div><!-- /noTTS -->
<!-- noTTS -->
<p>If the volume blasts your eardrums, adjust:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>amixer -c <span style="color:#ae81ff">1</span> sset PCM 20%
</span></span></code></pre></div><!-- /noTTS -->
<p>We can then deploy the jacadi api using ansible (or simply run the <code>docker-compose.yml</code> from the repo):</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>- <span style="color:#f92672">hosts</span>: <span style="color:#ae81ff">raspberry-pi</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">roles</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">role</span>: <span style="color:#ae81ff">ansible-role-jacadi</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">jacadi_audiodev</span>: <span style="color:#e6db74">&#34;plughw:1,0&#34;</span> <span style="color:#75715e"># adjust to match your card</span>
</span></span></code></pre></div><!-- /noTTS -->
<h3 id="supporting-new-devices">Supporting new devices</h3>
<p>The shipped image only contains commands for the Dreame L40 Ultra, but this can easily be expanded for other devices.</p>
<h4 id="add-commands-to-the-image">Add commands to the image</h4>
<p>Create a new set of commands in jacadi&rsquo;s <code>routes/</code> folder (check <a href="https://github.com/nbr23/jacadi/blob/master/routes/dreame.json" target="_blank" rel="noopener noreferrer">dreame.json</a> for reference), then build your custom image:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker build --target slim --build-arg ROUTES<span style="color:#f92672">=</span>mydevice -t jacadi:mydevice .
</span></span></code></pre></div><!-- /noTTS -->
<p>The new audio files will be generate during build and baked into the image.</p>
<h4 id="add-commands-through-mounted-volume">Add commands through mounted volume</h4>
<p>The docker images tagged <code>full</code> embed <a href="https://wip.tf/posts/tts-setup/" target="_blank" rel="noopener noreferrer">piper</a> and can generate text to speech audio files at runtime or startup. Creating and mounting an extra_routes file to your container will generate the missing audio files at start up. See <a href="https://github.com/nbr23/jacadi?tab=readme-ov-file#extra-routes" target="_blank" rel="noopener noreferrer">jacadi&rsquo;s README for details</a></p>
<h3 id="home-assistant">Home Assistant</h3>
<h4 id="generating-the-rest_command-yaml-list">Generating the <code>rest_command</code> yaml list</h4>
<p>With the API up and running, we can generate the corresponding Home-Assistant <code>rest_command</code> using the <code>generate-homeassistant</code> script in the <a href="https://github.com/nbr23/jacadi/blob/master/cmd/generate-homeassistant/main.go" target="_blank" rel="noopener noreferrer">jacadi</a> repo:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go run cmd/generate-homeassistant/main.go -base-url<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http://jacadi.local:8080&#34;</span> -device<span style="color:#f92672">=</span>dreame
</span></span></code></pre></div><!-- /noTTS -->
<p><img src="images/ha-gen.png" alt="generate-homeassistant config"></p>
<p>This will generate the <code>ha-config/homeassistant-rest.yml</code> file that contains a mapping of all the routes to home-assistant rest commands. We paste them in the configuration.yml&rsquo;s <code>rest_command</code> entry:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">rest_command</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">jacadi_dreame_battery_level</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">url</span>: <span style="color:#ae81ff">http://jacadi.local:8080/play/dreame/battery-level</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">method</span>: <span style="color:#ae81ff">post</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">jacadi_dreame_clean_balcony</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">url</span>: <span style="color:#ae81ff">http://jacadi.local:8080/play/dreame/clean-balcony</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">method</span>: <span style="color:#ae81ff">post</span>
</span></span><span style="display:flex;"><span>[<span style="color:#ae81ff">...]</span>
</span></span></code></pre></div><!-- /noTTS -->
<h4 id="wake-word-script">Wake word script</h4>
<p>For the Dreame vacuum, all commands need the wake word (&ldquo;Okay Dreame&rdquo;) spoken before, so we can add this small script to our HA config:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">vacuum_command</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#e6db74">&#34;Vacuum Command with Wake&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">fields</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">description</span>: <span style="color:#e6db74">&#34;The rest_command to execute after wake up&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">example</span>: <span style="color:#e6db74">&#34;jacadi_dreame_clean_balcony&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">sequence</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">service</span>: <span style="color:#ae81ff">rest_command.jacadi_dreame_ok_dream</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">delay</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">seconds</span>: <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">service</span>: <span style="color:#e6db74">&#34;rest_command.{{ command }}&#34;</span>
</span></span></code></pre></div><!-- /noTTS -->
<h4 id="dashboard">Dashboard</h4>
<p><a href="https://gist.github.com/nbr23/0ce30e2c6d3185624550c369b7318152" target="_blank" rel="noopener noreferrer">This dashboard</a> makes all of the vacuum cleaner&rsquo;s commands easy to invoke.</p>
<p><img src="images/ha-dashboard.png" alt="Home Assistant Vacuum Dashboard"></p>
<h2 id="caveats">Caveats</h2>
<h3 id="audio-annoyance">Audio annoyance</h3>
<p>Audio playback on the Pi&rsquo;s USB speaker from Go within Docker was frustrating. Getting the right audio encoding, bitrate, and device mapping working through Go audio libraries added complexity that wasn&rsquo;t worth it for this hack. Calling <code>aplay</code> with pre-generated wav files was the path of least resistance.</p>
<h3 id="unilateral-communication">Unilateral communication</h3>
<p>The robot listens to us, and acts, but we don&rsquo;t get any confirmation it has heard our command, or that the command was successfully performed.</p>
<h3 id="limited-controls">Limited controls</h3>
<p>We are limited to the set of commands Dreame has set up for voice recognition. They cannot be combined or chained. We cannot ask the robot to &ldquo;Vacuum only&rdquo; and &ldquo;Clean the bathroom&rdquo;. The &ldquo;Clean the bathroom&rdquo; command will clean the bathroom with whatever setting was last used through the app. We cannot ask for multiple rooms to be cleaned (and as we have no feedback when the cleaning is over, we need to add time buffers between manually chained tasks).</p>
<h3 id="inconsistent-names">Inconsistent names</h3>
<p>The room names in the app don&rsquo;t always map to the voice commands.
Here are a few mappings I figured out:</p>
<ul>
<li>Saying &ldquo;clean the hallway&rdquo; cleans the corridor</li>
<li>Saying &ldquo;clean the bedroom&rdquo; cleans all bedrooms</li>
<li>Saying &ldquo;clean the master room&rdquo; cleans the primary bedroom</li>
<li>Saying &ldquo;clean the guest room&rdquo; cleans the second bedroom</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>Despite a few caveats, this set up has allowed me, through Home-Assistant, to set up and easily update my home&rsquo;s cleaning schedule, fire one off cleaning actions whether I am home or not, and build some simple automations (like &ldquo;clean around the <a href="/posts/fixing-litter-robot-weight-tracking-multi-cat">litter box</a> after a cat has been in there&rdquo;), while keeping the vacuum cleaner fully offline and preserving some feeling of privacy.</p>
<p>A somewhat obvious next step would involve adding voice recognition to jacadi, and registering the robot&rsquo;s (very verbose) vocal feedback to home-assistant to get information about task success, things the robot wants me to fix or clean, etc. Although that would mean adding a new device listening in&hellip;</p>
<p>Although the only non-human voice activated device in my home is the Dreame, I am sure more applications can be found for this type of voice bridging with air-gapped devices.</p>

]]></content>
      </item>
    
      <item>
        <title>Fixing the Litter-Robot&#39;s SmartScale weight tracking with Home Assistant</title>
        <link>https://wip.tf/posts/fixing-litter-robot-weight-tracking-multi-cat/</link>
        <pubDate>Fri, 16 Jan 2026 22:02:07 -0500</pubDate>
        <guid>https://wip.tf/posts/fixing-litter-robot-weight-tracking-multi-cat/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/fixing-litter-robot-weight-tracking-multi-cat.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;My &lt;a href=&#34;https://wip.tf/tags/cats&#34;&gt;two cats&lt;/a&gt; recently acquired a &lt;a href=&#34;https://www.litter-robot.com/&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;Litter-Robot 4&lt;/a&gt;, and are now using it mostly full time after a few weeks of transition.&lt;/p&gt;
&lt;p&gt;Apart from promising to save precious manual scooping energy, shielding from the smell of the cats expertly timing their bowel movements with dinner time, the Litter-Robot 4 boasts a &lt;a href=&#34;https://www.litter-robot.com/smartscale&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;&amp;ldquo;SmartScale®&amp;rdquo;&lt;/a&gt;.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/fixing-litter-robot-weight-tracking-multi-cat.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>My <a href="/tags/cats">two cats</a> recently acquired a <a href="https://www.litter-robot.com/" target="_blank" rel="noopener noreferrer">Litter-Robot 4</a>, and are now using it mostly full time after a few weeks of transition.</p>
<p>Apart from promising to save precious manual scooping energy, shielding from the smell of the cats expertly timing their bowel movements with dinner time, the Litter-Robot 4 boasts a <a href="https://www.litter-robot.com/smartscale" target="_blank" rel="noopener noreferrer">&ldquo;SmartScale®&rdquo;</a>.</p>
<p>The SmartScale® promises, through <code>an advanced algorithm</code> to identify which cat is using the litter when, track their weight through time, and bathroom habits.</p>
<p>As silly as the need for tracking these metrics on a cat sounds, they were actually a pretty big selling point for us:</p>
<ul>
<li>Cat 1 has a history of urinary tract issues (which apparently is common for male ginger cats), which tends to manifest itself with him visiting the litter an unusual number of times (as he is unable to actually urinate).</li>
<li>Cat 2 is picky, and will pee on anything but litter if the box is not clean enough. This is mostly handled by the robot&rsquo;s autocleaning, but keeping track of his bathroom visits (or lack thereof) should warn us of a possible incident.</li>
</ul>
<p>After a few days of usage however, I was disappointed to notice that the SmartScale® is not actually that smart. To Litter-Robot&rsquo;s own admission, &ldquo;<a href="https://www.reddit.com/r/litterrobot/comments/1nz54db/comment/ni2w0fi/" target="_blank" rel="noopener noreferrer">Cats within 1 pound of each other may register incorrectly</a>&rdquo;.</p>
<p>As a casual home-assistant user, I built <a href="https://github.com/nbr23/litter-ha-dumbscale" target="_blank" rel="noopener noreferrer">litter-ha-DumbScale</a>, which relies on the <a href="https://www.home-assistant.io/integrations/litterrobot/" target="_blank" rel="noopener noreferrer">Litter-Robot</a> integration. SmartScale®&rsquo;s &lsquo;advanced algorithm&rsquo; is apparently too clever for its own good. DumbScale does the obvious thing: compare the current weight to each cat&rsquo;s last recorded weight, assign the visit to the closest match, and update accordingly.</p>
<figure class="img-side-by-side">
<div class="img-side-by-side-images">
<img src="images/sc-bad-cat.png" alt="" title="SmartScale® detecting Cat 1, always">
<img src="images/ha-good-cat.png" alt="" title="DumbScale detecting the right cat!">
</div>
<figcaption>SmartScale® seeing Cat 1 everywhere</figcaption>
</figure>
<p>There will still be limitations with cats that are very close in weight, especially as the scale doesn&rsquo;t appear to be of great accuracy, but this has worked well for me so far (Cat 1 and Cat 2&rsquo;s weight differ by slightly less than a pound).</p>
<p>I must also say that as Litter-Robot mentions <a href="https://www.reddit.com/r/litterrobot/comments/1nz54db/comment/ni2w0fi/" target="_blank" rel="noopener noreferrer">here</a>, the cat detection does get better with time, and after a few weeks I see less cat confusion.</p>
<p>There are also other issues where the SmartScale® will just skip attribution of box usage like these, which DumbScale fixes as well.</p>
<figure class="img-side-by-side">
<div class="img-side-by-side-images">
<img src="images/sc-skipped-cat.png" alt="" title="SmartScale® skipping attribution, even though it's obvious">
<img src="images/ha-notskipped-cat.png" alt="" title="DumbScale doing its job!">
</div>
<figcaption>SmartScale® discarding/not attributing pit stops</figcaption>
</figure>
<p>A month in, DumbScale hasn&rsquo;t misattributed a single visit. If you&rsquo;re running into the same issues, the <a href="https://github.com/nbr23/litter-ha-dumbscale" target="_blank" rel="noopener noreferrer">repo</a> has everything you need to set it up.</p>

]]></content>
      </item>
    
      <item>
        <title>Mapping my reading log by author birthplace</title>
        <link>https://wip.tf/posts/map-your-read-books-list-by-author-birthplace/</link>
        <pubDate>Tue, 30 Dec 2025 15:40:19 -0500</pubDate>
        <guid>https://wip.tf/posts/map-your-read-books-list-by-author-birthplace/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/map-your-read-books-list-by-author-birthplace.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;For the past few years, I have been keeping track of most of the books I read.&lt;/p&gt;
&lt;p&gt;It gives me some feeling of accomplishment and progress, adding a notch to the reading log, but also helps me keep track of what I&amp;rsquo;ve read, and where I am in the backlog of &lt;a href=&#34;https://en.wikipedia.org/wiki/Am%C3%A9lie_Nothomb&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;prolific authors&lt;/a&gt; or &lt;a href=&#34;https://en.wikipedia.org/wiki/The_Murderbot_Diaries&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;book series&lt;/a&gt;.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/map-your-read-books-list-by-author-birthplace.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>For the past few years, I have been keeping track of most of the books I read.</p>
<p>It gives me some feeling of accomplishment and progress, adding a notch to the reading log, but also helps me keep track of what I&rsquo;ve read, and where I am in the backlog of <a href="https://en.wikipedia.org/wiki/Am%C3%A9lie_Nothomb" target="_blank" rel="noopener noreferrer">prolific authors</a> or <a href="https://en.wikipedia.org/wiki/The_Murderbot_Diaries" target="_blank" rel="noopener noreferrer">book series</a>.</p>
<p>I initially tracked these books on goodreads, but it was <a href="https://en.wikipedia.org/wiki/Goodreads#2013_acquisition_by_Amazon" target="_blank" rel="noopener noreferrer">acquired by amazon</a> and later <a href="https://web.archive.org/web/20250228182523/https://www.goodreads.com/api" target="_blank" rel="noopener noreferrer">deprecated its API</a>, making the platform less desirable. Instead, I have been tracking them in a <a href="https://maxence.ardou.in/reading_log.html" target="_blank" rel="noopener noreferrer">simple markdown file I run through some templating/pandoc and push to my personal site</a>.</p>
<p>Being French and living in the US, I tend to mostly read books by French or American and British authors, but wanted a clearer picture. So I built <a href="https://github.com/nbr23/around-the-word/" target="_blank" rel="noopener noreferrer">around-the-word</a> to <a href="https://maxence.ardou.in/authors_map.html" target="_blank" rel="noopener noreferrer">put the authors of the books I read on a map</a>.</p>
<p><a href="https://maxence.ardou.in/authors_map.html" target="_blank" rel="noopener noreferrer"><img src="images/map.png" alt="" title="a world map with the distribution of authors I have read, by birthplace"></a></p>
<p>The map is an interactive HTML country-level map — hover over countries to see counts.</p>
<p>The <a href="https://github.com/nbr23/around-the-word/" target="_blank" rel="noopener noreferrer">python script</a> that generates it takes a <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">goodreads export</a> or a simple markdown list and looks up each author on Wikidata (with a lazy Wikipedia parser fallback) and maps their birthplace. Results get cached to JSON, so you can manually fix the inevitable historical and geopolitical edge cases: borders change, colonization and decolonization happened. George Orwell was born in India, but is considered British.</p>
<p>The map is rendered using <a href="https://d3js.org/d3-geo" target="_blank" rel="noopener noreferrer">D3</a> and <a href="https://github.com/topojson/world-atlas" target="_blank" rel="noopener noreferrer">topojson&rsquo;s world atlas</a>. The resulting HTML file embeds all the javascript it needs.</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>uvx around-the-word -i goodreads_export.csv -f goodreads -c cache.json -o map.html
</span></span></code></pre></div><!-- /noTTS -->
<p>The result confirms what I suspected: my reading is overwhelmingly Western. The US dominates (partly due to technical books), followed by France, the UK, and Canada. Beyond that, the map is sparse — a few books from South America, the rest of Europe and a handful of other countries leaving entire continents close to empty.</p>
<p>If you have suggestions from regions I haven&rsquo;t explored, <a href="mailto:max@wip.tf">send them my way</a>!</p>

]]></content>
      </item>
    
      <item>
        <title>Building Téléfonefix - Baby&#39;s first international landline</title>
        <link>https://wip.tf/posts/telefonefix-building-babys-first-international-landline/</link>
        <pubDate>Tue, 23 Sep 2025 05:31:06 -0400</pubDate>
        <guid>https://wip.tf/posts/telefonefix-building-babys-first-international-landline/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/telefonefix-building-babys-first-international-landline.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;&lt;img src=&#34;images/telefonefix-logo.png#floatright&#34; alt=&#34;&#34; title=&#34;a gaulois·e placing intercontinental calls using telefonefix&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Téléfonefix&lt;/strong&gt; is a &lt;strong&gt;kid-friendly&lt;/strong&gt; telephone system allowing kids to &lt;strong&gt;safely&lt;/strong&gt; call relatives, locally and abroad.&lt;/p&gt;
&lt;p&gt;The main features I sought when building téléfonefix were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Do not expose the user to a screen (no apps or fancy phones)&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;Use a physical phone&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;Perform calls locally and internationally&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;Permit/decline calls based on a ruleset. Specifically:
&lt;ul&gt;
&lt;li&gt;only permit calls to be made to an adult-controlled set of numbers&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;timezones and &amp;ldquo;awake/asleep&amp;rdquo; schedules, to avoid calling Europe at their 1am&lt;/li&gt;
&lt;li&gt;prevent inbound calls entirely&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;As user-friendly as possible&lt;span style=&#34;display:none&#34;&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;shopping-list&#34;&gt;Shopping list&lt;/h2&gt;
&lt;p&gt;&lt;img src=&#34;images/telefonefix-full.jpeg#smaller&#34; alt=&#34;&#34; title=&#34;the phone plugged to the HT801 Grandstream plugged to the Raspberry Pi 4B, beaming to the PTSN through twilio&#34;&gt;&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/telefonefix-building-babys-first-international-landline.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p><img src="images/telefonefix-logo.png#floatright" alt="" title="a gaulois·e placing intercontinental calls using telefonefix"></p>
<p><strong>Téléfonefix</strong> is a <strong>kid-friendly</strong> telephone system allowing kids to <strong>safely</strong> call relatives, locally and abroad.</p>
<p>The main features I sought when building téléfonefix were:</p>
<ul>
<li>Do not expose the user to a screen (no apps or fancy phones)<span style="display:none">.</span></li>
<li>Use a physical phone<span style="display:none">.</span></li>
<li>Perform calls locally and internationally<span style="display:none">.</span></li>
<li>Permit/decline calls based on a ruleset. Specifically:
<ul>
<li>only permit calls to be made to an adult-controlled set of numbers<span style="display:none">.</span></li>
<li>timezones and &ldquo;awake/asleep&rdquo; schedules, to avoid calling Europe at their 1am</li>
<li>prevent inbound calls entirely<span style="display:none">.</span></li>
</ul>
</li>
<li>As user-friendly as possible<span style="display:none">.</span></li>
</ul>
<h2 id="shopping-list">Shopping list</h2>
<p><img src="images/telefonefix-full.jpeg#smaller" alt="" title="the phone plugged to the HT801 Grandstream plugged to the Raspberry Pi 4B, beaming to the PTSN through twilio"></p>
<ul>
<li>A <strong>corded phone</strong>: any phone with an RJ11 connector will work. A rotary dial phone would add cool factor for kids old enough to dial them. I used this <a href="https://www.amazon.com/dp/B0CN7BZ5N8" target="_blank" rel="noopener noreferrer">&ldquo;Telephone for Seniors&rdquo;</a>. The pictured speed dial numbers are perfect for very young kids to choose who they want to call<span style="display:none">.</span></li>
<li>An <strong>analog telephone adapter</strong>: I use the <a href="https://www.amazon.com/Grandstream-HT801-Single-Port-Telephone-Adapter/dp/B06XW1BQHC" target="_blank" rel="noopener noreferrer">Grandstrean HT801</a>. It is well-documented and reliable<span style="display:none">.</span></li>
<li>A <strong>Raspberry Pi</strong> (or any compute really) to run the software PBX <strong>asterisk</strong> on. For this setup, I used a Pi 4 B. It embeds WiFi and an ethernet port making it easy to connect both to your network and directly wire the HT801 to the Pi<span style="display:none">.</span></li>
<li>A <strong>twilio</strong> account with a number purchased (~ $1.15/month)<span style="display:none">.</span></li>
</ul>
<h2 id="architecture">Architecture</h2>
<p><img src="images/diagram.svg#center" alt="" title="The phone connects to the HT801. The HT801 to asterisk running on the Raspberry Pi. Asterisk queries allo-wed for call approval, and places the call through twilio"></p>
<h2 id="hardware-setup">Hardware setup</h2>
<h3 id="phone-to-ht801">Phone to HT801</h3>
<p>We simply plug an <code>RJ11</code> cable in between the Phone and the HT801.</p>
<h3 id="ht801-to-raspberry-pi">HT801 to Raspberry Pi</h3>
<p>I opted to connect these two directly with an <code>RJ45</code> as we will see below.</p>
<h3 id="phone">Phone</h3>
<p>On the phone itself, to make updating phone numbers easier, I assigned each of the 9 speed dial picture buttons with a number from <code>100</code> to <code>107</code> and assigned <code>200</code> to the last one (which I keep for debugging). Those extensions will be translated later on to actual numbers by a small golang program I built for the occasion, <a href="https://github.com/nbr23/allo-wed/" target="_blank" rel="noopener noreferrer">allo-wed</a>.</p>
<p><img src="images/telefonefix-phone.jpeg#xsmaller" alt="" title="A phone designed for old humans, that works great with young humans too"></p>
<h2 id="twilio-setup">Twilio setup</h2>
<p>First, you will need to create an <a href="https://console.twilio.com/us1/develop/sip-trunking/manage/trunks?frameUrl=%2Fconsole%2Fsip-trunking%2Ftrunks%3Fx-target-region%3Dus1" target="_blank" rel="noopener noreferrer">Elastic SIP Trunk</a> in twilio.</p>
<p><img src="images/twilio-sip.png#center" alt="" title="A screenshot of the twilio Elastic SIP Trunk page"></p>
<p>Once created, in the <em>Termination</em> tab, set up the <em>Authentication</em>. Create a new set of credentials (your <code>telefonefix_twilio_user</code> and <code>telefonefix_twilio_password</code> in <strong>asterisk</strong>&rsquo;s configuration&rsquo;). IP Access control lists are also available.</p>
<p>In the <em>Numbers</em> tab, buy a new number and assign it to the trunk. That number will be your <code>telefonefix_twilio_phone_number</code> value (formatted as <code>'+15555555555'</code>). Note that numbers have a monthly cost independent of usage (~ $1.15/month).</p>
<p>Incoming calls are not something I am looking for, so I did not change <em>Origination</em>.</p>
<h2 id="téléfonefix-stack-configuration">Téléfonefix stack configuration</h2>
<p>I built <a href="https://github.com/nbr23/ansible-role-telefonefix" target="_blank" rel="noopener noreferrer">ansible-role-telefonefix</a> to handle the configuration.
Let&rsquo;s look at a <code>telefonefix.yml</code> playbook example and detail each task:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span>- <span style="color:#f92672">hosts</span>: <span style="color:#ae81ff">telefonefix</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">become</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">gather_facts</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">vars</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_asterisk_playback_patterns</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">connecting</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;Un instant, je vous connecte à \&#34;${contact_name}\&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;Só um momento. Ligando para \&#34;${contact_name}.\&#34;&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">not-allowed</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; fait dodo, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está dormindo, ligue mais tarde!&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">currently-busy</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; est occupé, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está occupado, ligue mais tarde!&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">unavailable</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; est occupé, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está occupado, ligue mais tarde!&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_voice_map</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">en</span>: <span style="color:#e6db74">&#34;en_US-amy-low&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;fr_FR-siwis-low&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;pt_BR-cadu-medium&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_network_subnet</span>: <span style="color:#e6db74">&#34;192.168.100.0/24&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_gateway_ip</span>: <span style="color:#e6db74">&#34;192.168.100.1&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ht801_mac</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ht801_static_ip</span>: <span style="color:#e6db74">&#34;192.168.100.20&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_dns_servers</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8.8.8.8&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8.8.4.4&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_ethernet_interface</span>: <span style="color:#e6db74">&#34;eth0&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_asterisk_extra_sounds_folder</span>: <span style="color:#ae81ff">/tmp/telefonefix/extra_sounds</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_super_user_override_prefix</span>: <span style="color:#e6db74">&#39;23&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_public_ip</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_asterisk_phone_user</span>: <span style="color:#e6db74">&#39;6001&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_asterisk_phone_password</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_twilio_domaine</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_twilio_phone_number</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_twilio_user</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_twilio_password</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ht801_password</span>: <span style="color:#75715e"># TODO</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">tasks</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Generating asterisk voices</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">include_role</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ansible-role-telefonefix</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">tasks_from</span>: <span style="color:#ae81ff">asterisk_playbacks_generate.yml</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">DHCP config</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">include_role</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ansible-role-telefonefix</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">tasks_from</span>: <span style="color:#ae81ff">dhcp.yml</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Asterisk setup</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">include_role</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ansible-role-telefonefix</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">tasks_from</span>: <span style="color:#ae81ff">asterisk.yml</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">HT801 setup</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">include_role</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ansible-role-telefonefix</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">tasks_from</span>: <span style="color:#ae81ff">ht801.yml</span>
</span></span></code></pre></div><!-- /noTTS -->
<p>The variables are mostly self-explanatory and can be updated to fit different needs and setups.</p>
<p>We will review the <code>Generating asterisk voices</code> task at the end, and start with the main tasks first.</p>
<h3 id="task-dhcp-config">Task <code>DHCP config</code></h3>
<p>Configures a dhcp server on the raspberry pi, and has it assign a fixed ip to the HT801 in their own shared very local LAN to keep things isolated and simple.</p>
<p>We can also easily run asterisk on a VPS, and have the HT801 point directly to it.</p>
<h3 id="task-asterisk-setup">Task <code>Asterisk setup</code></h3>
<p>This is the main task. It:</p>
<ul>
<li>installs docker<span style="display:none">.</span></li>
<li>pushes the main configuration files<span style="display:none">.</span></li>
<li>pushes optional sound files for asterisk<span style="display:none">.</span></li>
<li>launches the <code>asterisk</code> docker container (<a href="https://github.com/nbr23/docker-asterisk" target="_blank" rel="noopener noreferrer">asterisk</a> + <a href="https://github.com/nbr23/allo-wed/blob/master/Dockerfile" target="_blank" rel="noopener noreferrer">allo-wed</a>)<span style="display:none">.</span></li>
</ul>
<p>Let&rsquo;s review the main configuration files.</p>
<h4 id="file-extensionsconf">File <code>extensions.conf</code></h4>
<p><a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf" target="_blank" rel="noopener noreferrer">extensions.conf</a> defines what happens when a number is dialed.</p>
<p>On the physical phone, I have defined each speed dial pictured number to dial a number from <code>100</code> to <code>107</code>. The last picture is defined as <code>200</code>, and currently <a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf#L47-L51" target="_blank" rel="noopener noreferrer">used for debugging</a>.</p>
<p>Calls coming in using one of those numbers are handled here:</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">extensions.conf#L17-L21</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf#L17-L21" target="_blank" rel="noopener noreferrer">view code (lines 17-21)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; Pattern _1XX matches any three-digit number starting with 1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,1,NoOp(Calling extension ${EXTEN})</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,Set(TRANSLATED_NUMBER=${SHELL(allo-wed -config /opt/asterisk/allo-wed.yml -is-allowed -phone ${EXTEN})})</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,NoOp(Translated to: ${TRANSLATED_NUMBER})</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,GotoIf($[&#34;${TRANSLATED_NUMBER}&#34; = &#34;&#34;]?not_allowed)</span></span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>We call the <a href="https://github.com/nbr23/allo-wed" target="_blank" rel="noopener noreferrer">allo-wed</a> script with the dialed extension as a parameter. If calls are allowed to this number, it returns the <code>1XX</code> number to real world number translation, based on its <a href="https://github.com/nbr23/allo-wed/blob/master/config.sample.yaml" target="_blank" rel="noopener noreferrer">configuration file</a>.</p>
<p>If the call is denied, we let the user know:</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">extensions.conf#L31-L33</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf#L31-L33" target="_blank" rel="noopener noreferrer">view code (lines 31-33)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; Call not allowed or no mapping found</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n(not_allowed),Playback(extra/${EXTEN}/not-allowed)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,Hangup()</span></span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>Otherwise, we announce success and perform the call:</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">extensions.conf#L23-L29</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf#L23-L29" target="_blank" rel="noopener noreferrer">view code (lines 23-29)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; Call is allowed and number was translated</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,Playback(extra/${EXTEN}/connecting)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,Dial(PJSIP/${TRANSLATED_NUMBER}@twilio,30,L({{ telefonefix_call_duration_limit_minutes * 60 * 1000 }}))</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,GotoIf($[&#34;${DIALSTATUS}&#34; = &#34;BUSY&#34;]?busy)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,GotoIf($[&#34;${DIALSTATUS}&#34; = &#34;NOANSWER&#34;]?noanswer)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,GotoIf($[&#34;${DIALSTATUS}&#34; = &#34;UNAVAILABLE&#34;]?unavailable)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">exten</span> <span style="color:#f92672">=</span><span style="color:#e6db74">&gt; _1XX,n,Hangup()</span></span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>A <strong><a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/extensions.conf#L2-L15" target="_blank" rel="noopener noreferrer">parental override</a></strong> can be enabled by setting a <code>telefonefix_super_user_override_prefix</code> value.</p>
<p>For example, with <code>telefonefix_super_user_override_prefix = 23</code>, you will be able to bypass call restrictions on the <code>101</code> extensions by dialing <code>23101</code>.</p>
<h4 id="file-pjsipconf">File <code>pjsip.conf</code></h4>
<p>This is where we configure the actual communication stack.</p>
<p><a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/pjsip.conf#L28-L47" target="_blank" rel="noopener noreferrer">First</a> we setup our <strong>local extension</strong> <code>[6001]</code>, our physical phone, and set up authentication for it so the HT801 can connect to asterisk.</p>
<p><a href="https://github.com/nbr23/ansible-role-telefonefix/blob/master/templates/asterisk/pjsip.conf#L49-L92" target="_blank" rel="noopener noreferrer">Then</a> we set up the <strong>Twilio trunk</strong> for outbound calls, linking and authenticating to our domain.</p>
<h4 id="file-rtpconf">File <code>rtp.conf</code></h4>
<p>The RTP configuration is lightly edited to reduce the port range. We have a single user, and not having to add port forwarding for the full range can make home router/firewall configuration easier.</p>
<h4 id="file-allo-wedyml">File <code>allo-wed.yml</code></h4>
<p>You need an <code>asterisk/allo-wed.yml</code> file alongside your calling playbook (eg in <code>files/asterisk/allo-wed.yml</code>). A configuration example is available in the <a href="https://github.com/nbr23/allo-wed/blob/master/config.sample.yaml" target="_blank" rel="noopener noreferrer">allo-wed repo</a>.</p>
<p>This file will define who each speed dial extension will call, their language and name (for fancy spoken messages), their timezone and awake hours, and the number to dial.</p>
<p>Number format tends to be the tricky part here. I have had success putting the full number with country code and leading <code>+</code>. For example, to dial a French cell: <code>'+336XXXXXXXX'</code>.</p>
<h3 id="task-ht801-setup">Task <code>HT801 setup</code></h3>
<p>We send a <code>POST</code> request to the HT801&rsquo;s administration API, essentially setting up communication and authentication with the asterisk server.</p>
<h3 id="bonus-generating-asterisk-voices">Bonus: Generating asterisk voices</h3>
<p>Using my previous <strong><a href="https://wip.tf/posts/tts-setup/" target="_blank" rel="noopener noreferrer">Text To Speech Setup</a></strong> and <strong>asterisk</strong>&rsquo;s <code>Playback</code> feature, I generate custom messages that asterisk will play for different occasion. For example, when dialing a specific contact, asterisk will announce in the callee&rsquo;s language <em>&ldquo;Dialing XXX, please hold&rdquo;</em>.</p>
<p>The messages are generated based on these variables:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_asterisk_playback_patterns</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">connecting</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;Un instant, je vous connecte à \&#34;${contact_name}\&#34;&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;Só um momento. Ligando para \&#34;${contact_name}.\&#34;&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">not-allowed</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; fait dodo, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está dormindo, ligue mais tarde!&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">currently-busy</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; est occupé, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está occupado, ligue mais tarde!&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">unavailable</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}\&#34; est occupé, rappelle plus tard!&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;\&#34;${contact_name}.\&#34; está occupado, ligue mais tarde!&#34;</span>
</span></span></code></pre></div><!-- /noTTS -->
<p>Using these voices, which I found to be the best for each language I was interested in:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span>    <span style="color:#f92672">telefonefix_voice_map</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">en</span>: <span style="color:#e6db74">&#34;en_US-amy-low&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">fr</span>: <span style="color:#e6db74">&#34;fr_FR-siwis-low&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">pt</span>: <span style="color:#e6db74">&#34;pt_BR-cadu-medium&#34;</span>
</span></span></code></pre></div><!-- /noTTS -->
<p>The <code>Generating asterisk voices</code> task will start <a href="https://github.com/nbr23/gopipertts" target="_blank" rel="noopener noreferrer">gopipertts</a> on the local machine, generate all the messages we need, and convert them to be playable by asterisk.</p>
<h2 id="firewall-configuration">Firewall configuration</h2>
<p>Twilio needs to be able to reach asterisk on the following ports, you will need to configure your firewall and port forwarding accordingly:</p>
<ul>
<li><code>5060/udp</code>: SIP port<span style="display:none">.</span></li>
<li><code>10000-10100/udp</code>: the RTP port range<span style="display:none">.</span></li>
</ul>
<h2 id="conclusion-and-future-improvements">Conclusion and future improvements</h2>
<p>The phone has been a huge success, and the concept easily graspable by young kids!</p>
<p>A few potential improvements:</p>
<ul>
<li>preventing hangups&hellip; Kids get excited, pick up the phone, hang it up, pick it up again. Being able to prevent premature call end would be useful (twilio starts charging immediately too, so you&rsquo;ll be billed the full minute for that 3s call-hang-up)<span style="display:none">.</span></li>
<li>limiting calls per day/hours? It could be useful to have <code>allo-wed</code> prevent the user from calling the same contact 15 times within a minute<span style="display:none">.</span></li>
<li>better error handling. There are cases where calls fail with little to no feedback. I&rsquo;m not sure where the issues come from, but gathering more information from twilio/asterisk and speaking it to the user would be helpful<span style="display:none">.</span></li>
<li>forcing speaker mode&hellip; But that would require tweaking with the phone itself<span style="display:none">.</span></li>
</ul>
<p><strong>If you implement this and have feedback, please <a href="mailto:max@wip.tf">reach out</a>!</strong></p>

<h2 id="links">Links</h2>
<h3 id="core-components">Core Components</h3>
<ul>
<li><a href="https://www.asterisk.org/" target="_blank" rel="noopener noreferrer">Asterisk</a> - The open source PBX system</li>
<li><a href="https://console.twilio.com/" target="_blank" rel="noopener noreferrer">Twilio</a> - SIP trunking and phone number provider</li>
<li><a href="https://www.amazon.com/Grandstream-HT801-Single-Port-Telephone-Adapter/dp/B06XW1BQHC" target="_blank" rel="noopener noreferrer">Grandstream HT801</a> - Analog telephone adapter</li>
</ul>
<h3 id="software--configuration">Software &amp; Configuration</h3>
<ul>
<li><a href="https://github.com/nbr23/allo-wed" target="_blank" rel="noopener noreferrer">allo-wed</a> - Extension translation and call scheduling</li>
<li><a href="https://github.com/nbr23/ansible-role-telefonefix" target="_blank" rel="noopener noreferrer">ansible-role-telefonefix</a> - Ansible automation for the entire setup</li>
<li><a href="https://github.com/nbr23/docker-asterisk" target="_blank" rel="noopener noreferrer">docker-asterisk</a> - Ubuntu-based Asterisk container</li>
<li><a href="https://github.com/nbr23/gopipertts" target="_blank" rel="noopener noreferrer">gopipertts</a> - Text-to-speech voice generation</li>
</ul>
<h3 id="references--inspiration">References &amp; Inspiration</h3>
<ul>
<li><a href="https://www.michaellunzer.com/blogs/connecting-a-grandstream-ht80x-ht801-or-ht802-to-twilio-sip" target="_blank" rel="noopener noreferrer">Connecting a Grandstream HT80x to Twilio SIP</a> - Helpful setup guide</li>
<li><a href="https://nickbusey.com/article/2020-05-04-twilio-grandstream/" target="_blank" rel="noopener noreferrer">Twilio + Grandstream Setup</a> - Another useful walkthrough</li>
<li><a href="https://en.wikipedia.org/wiki/Asterix" target="_blank" rel="noopener noreferrer">Astérix et Obélix</a> - Naming convention inspiration</li>
</ul>
]]></content>
      </item>
    
      <item>
        <title>Jira compliant and Claude powered commits</title>
        <link>https://wip.tf/posts/jira-claude-commit/</link>
        <pubDate>Tue, 12 Aug 2025 23:47:26 -0400</pubDate>
        <guid>https://wip.tf/posts/jira-claude-commit/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/jira-claude-commit.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;Using the same &lt;a href=&#34;https://github.com/nbr23/jira-buddy&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34; class=&#34;github-link&#34;&gt;&lt;span&gt;jira-buddy&lt;/span&gt;&lt;/a&gt; from &lt;a href=&#34;https://wip.tf/posts/vim-jira/&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;earlier&lt;/a&gt; and combining it with claude code, we can stop writing commits entirely and let the LLMs do it.&lt;/p&gt;
&lt;p&gt;This &lt;a href=&#34;https://gist.github.com/nbr23/33cfa30b3dd2b724ec28c08948016379&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;gist&lt;/a&gt; suggests two bash functions to add to your &lt;code&gt;bashrc&lt;/code&gt; to never write commits again!&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/jira-claude-commit.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>Using the same <a href="https://github.com/nbr23/jira-buddy" target="_blank" rel="noopener noreferrer" class="github-link"><span>jira-buddy</span></a> from <a href="https://wip.tf/posts/vim-jira/" target="_blank" rel="noopener noreferrer">earlier</a> and combining it with claude code, we can stop writing commits entirely and let the LLMs do it.</p>
<p>This <a href="https://gist.github.com/nbr23/33cfa30b3dd2b724ec28c08948016379" target="_blank" rel="noopener noreferrer">gist</a> suggests two bash functions to add to your <code>bashrc</code> to never write commits again!</p>
<p>This first function, <code>ai-commit</code>, passes:</p>
<ul>
<li>the staged changes (whatever you <code>git add</code>&lsquo;ed)</li>
<li>the current directory&rsquo;s name (you&rsquo;ll want to run this at the root of the repo, so this would likely be your project name, which adds some context to your change)</li>
<li>anything you pass to the command as extra context</li>
</ul>
<p>to <code>claude code</code> and asks it for a concise commit message.</p>
<!-- noTTS -->
<p>For example:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>host:~/dev/superapp&gt; ai-commit ai powered security
</span></span></code></pre></div><div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">ai-commits.sh</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://gist.github.com/nbr23/33cfa30b3dd2b724ec28c08948016379#file-ai-commits-sh-L1-L27" target="_blank" rel="noopener noreferrer">view gist (lines 1-27)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ai-commit<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  local profile<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>DEFAULT_CLAUDE_COMMIT_PROFILE<span style="color:#66d9ef">:-</span>claude<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  local context<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Generating commit message with Claude using profile &#39;</span>$profile<span style="color:#e6db74">&#39;... with extra context: &#39;</span>$context<span style="color:#e6db74">&#39;&#34;</span>
</span></span><span style="display:flex;"><span>  local project_dir_name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>PWD##*/<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  local commit_message
</span></span><span style="display:flex;"><span>  local prompt<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Analyze the staged git changes for the &#39;</span>$project_dir_name<span style="color:#e6db74">&#39; project and generate a concise commit message following conventional commit format (type: description). Keep the message under 72 characters for the first line. Only output the commit message, nothing else - the output goes directly to git commit -m.&#34;</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span>$context<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    prompt<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$prompt<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Additional context: </span>$context<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  commit_message<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>docker run --rm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$PWD<span style="color:#e6db74">:/home/node/dev/</span>$project_dir_name<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    -w <span style="color:#e6db74">&#34;/home/node/dev/</span>$project_dir_name<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$HOME<span style="color:#e6db74">/.</span><span style="color:#e6db74">${</span>profile<span style="color:#e6db74">}</span><span style="color:#e6db74">:/home/node/.claude&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$HOME<span style="color:#e6db74">/.</span><span style="color:#e6db74">${</span>profile<span style="color:#e6db74">}</span><span style="color:#e6db74">.json:/home/node/.claude.json&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    nbr23/claudecode -p <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>prompt<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> | awk <span style="color:#e6db74">&#39;{$1=$1;print}&#39;</span><span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Failed to generate a commit message from Claude. Aborting.&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Committing...&#34;</span>
</span></span><span style="display:flex;"><span>  git commit -m <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">&amp;&amp;</span> git commit --amend
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span></span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p><code>ai-jira-commit</code> works similarly to <code>ai-commit</code> but also calls <code>jira-buddy</code> to fetch the list of open tickets assigned to ourselves on Jira. It passes this list as extra context to <code>claude code</code>, and asks it to delivery a commit message prepended with the most appropriate ticket ID.</p>
<!-- noTTS -->
<div class="github-embed">
            <div class="github-embed-header">
                <span class="github-embed-header-left">
                    <svg width="14" height="14" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="currentColor"/></svg>
                    <span class="github-embed-title">ai-commits.sh</span>
                </span>
                <span class="github-embed-header-right">
                    <a href="https://gist.github.com/nbr23/33cfa30b3dd2b724ec28c08948016379#file-ai-commits-sh-L29-L113" target="_blank" rel="noopener noreferrer">view gist (lines 29-113)</a></span>
            </div>
            <div class="github-embed-body"><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ai-jira-commit<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  local profile<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>DEFAULT_CLAUDE_COMMIT_PROFILE<span style="color:#66d9ef">:-</span>claude<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  local context<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Generating Jira-aware commit message with Claude using profile &#39;</span>$profile<span style="color:#e6db74">&#39;... with extra context: &#39;</span>$context<span style="color:#e6db74">&#39;&#34;</span>
</span></span><span style="display:flex;"><span>  local project_dir_name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>PWD##*/<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  local commit_message
</span></span><span style="display:flex;"><span>  local jira_tickets
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  jira_tickets<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>uvx jira-buddy --own --json --fields key,summary,status,issuetype<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$jira_tickets<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Failed to fetch Jira tickets.&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  local prompt<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;CRITICAL INSTRUCTIONS: You MUST respond with ONLY a single line containing the commit message. NO explanations, NO reasoning, NO additional text, NO line breaks.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Analyze the staged git changes for the &#39;</span>$project_dir_name<span style="color:#e6db74">&#39; project. Based on the changes and the following Jira tickets JSON, pick the most appropriate ticket and generate a commit message in the format &#39;TICKET-KEY - commit message&#39;. Keep under 72 characters total.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">IMPORTANT: Only pick a ticket if you have confidence it matches the changes or that the changes could be a subtask of. If no ticket clearly matches, respond with exactly &#39;None&#39; instead.&#34;</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span>$context<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    prompt<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$prompt<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Additional context: </span>$context<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  prompt<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$prompt<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Jira tickets:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"></span>$jira_tickets<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">RESPOND WITH ONLY THE COMMIT MESSAGE LINE. NOTHING ELSE.&#34;</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  local claude_response
</span></span><span style="display:flex;"><span>  claude_response<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>docker run --rm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$PWD<span style="color:#e6db74">:/home/node/dev/</span>$project_dir_name<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    -w <span style="color:#e6db74">&#34;/home/node/dev/</span>$project_dir_name<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$HOME<span style="color:#e6db74">/.</span><span style="color:#e6db74">${</span>profile<span style="color:#e6db74">}</span><span style="color:#e6db74">:/home/node/.claude&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --volume <span style="color:#e6db74">&#34;</span>$HOME<span style="color:#e6db74">/.</span><span style="color:#e6db74">${</span>profile<span style="color:#e6db74">}</span><span style="color:#e6db74">.json:/home/node/.claude.json&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    nbr23/claudecode -p <span style="color:#e6db74">&#34;</span>$prompt<span style="color:#e6db74">&#34;</span> | awk <span style="color:#e6db74">&#39;{$1=$1;print}&#39;</span><span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$claude_response<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Failed to generate a commit message from Claude. Aborting.&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> <span style="color:#e6db74">&#34;</span>$claude_response<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;None&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;No appropriate Jira ticket found.&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  commit_message<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>echo <span style="color:#e6db74">&#34;</span>$claude_response<span style="color:#e6db74">&#34;</span> | grep -oE <span style="color:#e6db74">&#39;^[A-Z]+-[0-9]+ - .*$&#39;</span> | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Claude response doesn&#39;t match expected commit message format.&#34;</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Claude response: </span>$claude_response<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  local line_count
</span></span><span style="display:flex;"><span>  line_count<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>echo <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span> | wc -l<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> $line_count -gt <span style="color:#ae81ff">1</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Commit message must be only one line long.&#34;</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Generated message:&#34;</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  local ticket_key
</span></span><span style="display:flex;"><span>  ticket_key<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>echo <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span> | grep -oE <span style="color:#e6db74">&#39;^[A-Z]+-[0-9]+&#39;</span> | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>  
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -z <span style="color:#e6db74">&#34;</span>$ticket_key<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Error: Commit message must start with a ticket number (e.g., ABC-123).&#34;</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Generated message: </span>$commit_message<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Selected Jira ticket: </span>$ticket_key<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;</span>$jira_tickets<span style="color:#e6db74">&#34;</span> | jq -r <span style="color:#e6db74">&#34;.[] | select(.key == \&#34;</span>$ticket_key<span style="color:#e6db74">\&#34;) | \&#34;Key: \(.key)\nSummary: \(.summary)\nStatus: \(.status)\nIssue Type: \(.issuetype)\&#34;&#34;</span>
</span></span><span style="display:flex;"><span>  echo
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Committing...&#34;</span>
</span></span><span style="display:flex;"><span>  git commit -m <span style="color:#e6db74">&#34;</span>$commit_message<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span></span></span></code></pre></div></div>
        </div>
<!-- /noTTS -->
<p>Both run claude inside <a href="https://github.com/nbr23/claudecode/" target="_blank" rel="noopener noreferrer">docker</a>, but can be easily modified to run natively.</p>
<p>YMMV, and sometimes you need to run it a couple of times before it accepts to deliver a commit message and not a monologue on git (despite the ALL CAPS instructions!!!), or figures out which LLM-generated ticket your PM created matches the LLM-generated code you staged.</p>

]]></content>
      </item>
    
      <item>
        <title>Lookup and insert Jira Ticket IDs in Vim</title>
        <link>https://wip.tf/posts/vim-jira/</link>
        <pubDate>Wed, 25 Jun 2025 22:01:22 -0400</pubDate>
        <guid>https://wip.tf/posts/vim-jira/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/vim-jira.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;Navigating Jira’s UI to find your Task/Epic/Bug is a pain. Even copying the ticket ID is its own quest. You end up switching tabs, losing focus, and forgetting what you were doing.&lt;/p&gt;
&lt;p&gt;To skip all that, I use a &lt;a href=&#34;https://github.com/nbr23/jira-buddy&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;jira-buddy&lt;/a&gt; and a Vim function to hit the Jira API, look up the ticket, and insert the ID directly into my document. I use it to insert the ticket ID for the current work in my commit messages.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/vim-jira.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>Navigating Jira’s UI to find your Task/Epic/Bug is a pain. Even copying the ticket ID is its own quest. You end up switching tabs, losing focus, and forgetting what you were doing.</p>
<p>To skip all that, I use a <a href="https://github.com/nbr23/jira-buddy" target="_blank" rel="noopener noreferrer">jira-buddy</a> and a Vim function to hit the Jira API, look up the ticket, and insert the ID directly into my document. I use it to insert the ticket ID for the current work in my commit messages.</p>
<p><img src="images/vim-jira.gif" alt=""></p>
<p>I tried without success to figure out which scopes my scoped API token needed. So I just use a personal API token instead.</p>

]]></content>
      </item>
    
      <item>
        <title>Listen to This Article</title>
        <link>https://wip.tf/posts/listen-to-this-article/</link>
        <pubDate>Fri, 06 Jun 2025 21:50:10 -0400</pubDate>
        <guid>https://wip.tf/posts/listen-to-this-article/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/listen-to-this-article.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;Now that we know how to &lt;a href=&#34;https://wip.tf/posts/tts-setup/&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;listen to other people&amp;rsquo;s content&lt;/a&gt;, let&amp;rsquo;s make sure other people can listen to our content!&lt;/p&gt;
&lt;p&gt;This blog is generated using &lt;a href=&#34;https://gohugo.io&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;hugo&lt;/a&gt; and uses a theme based on &lt;a href=&#34;https://github.com/SeriousBug/hugo-theme-catafalque&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;catafalque&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Leveraging my &lt;a href=&#34;https://github.com/nbr23/gopipertts&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;piper api wrapper&lt;/a&gt;, I created a &lt;a href=&#34;https://github.com/nbr23/hugo-shortcodes-tts&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;hugo-shortcodes-tts&lt;/a&gt;. The shortcode sends the hugo post&amp;rsquo;s content to the TTS API, saves it as a static file, and embeds a player into the post&amp;rsquo;s page as can be seen at the top of this article.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/listen-to-this-article.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>Now that we know how to <a href="https://wip.tf/posts/tts-setup/" target="_blank" rel="noopener noreferrer">listen to other people&rsquo;s content</a>, let&rsquo;s make sure other people can listen to our content!</p>
<p>This blog is generated using <a href="https://gohugo.io" target="_blank" rel="noopener noreferrer">hugo</a> and uses a theme based on <a href="https://github.com/SeriousBug/hugo-theme-catafalque" target="_blank" rel="noopener noreferrer">catafalque</a>.</p>
<p>Leveraging my <a href="https://github.com/nbr23/gopipertts" target="_blank" rel="noopener noreferrer">piper api wrapper</a>, I created a <a href="https://github.com/nbr23/hugo-shortcodes-tts" target="_blank" rel="noopener noreferrer">hugo-shortcodes-tts</a>. The shortcode sends the hugo post&rsquo;s content to the TTS API, saves it as a static file, and embeds a player into the post&rsquo;s page as can be seen at the top of this article.</p>
<p>Setup and configuration documentation is available in the shortcode&rsquo;s <a href="https://github.com/nbr23/hugo-shortcodes-tts#hugo-shortcodes-tts" target="_blank" rel="noopener noreferrer">README</a> for the hugo setup and in gopipertts&rsquo;s <a href="https://github.com/nbr23/gopipertts?tab=readme-ov-file#gopipertts" target="_blank" rel="noopener noreferrer">README</a> for the TTS API.</p>

]]></content>
      </item>
    
      <item>
        <title>My self-hosted TextToSpeech setup</title>
        <link>https://wip.tf/posts/tts-setup/</link>
        <pubDate>Sun, 13 Apr 2025 20:33:45 -0400</pubDate>
        <guid>https://wip.tf/posts/tts-setup/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/tts-setup.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;For a while, I had been using a simple keyboard shortcut for &lt;code&gt;xclip -o | espeak-ng&lt;/code&gt; as my TextToSpeech setup. I mostly use it in-browser, highlighting text and using the shortcut to speak the highlighted text.&lt;/p&gt;
&lt;p&gt;I have been meaning to update it, get more modern voices and make it easier to setup on heterogenous environments (fancy way to say I struggle to have reliable hotkey actions on my work macbook).&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/tts-setup.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>For a while, I had been using a simple keyboard shortcut for <code>xclip -o | espeak-ng</code> as my TextToSpeech setup. I mostly use it in-browser, highlighting text and using the shortcut to speak the highlighted text.</p>
<p>I have been meaning to update it, get more modern voices and make it easier to setup on heterogenous environments (fancy way to say I struggle to have reliable hotkey actions on my work macbook).</p>
<p>After setting up a <a href="https://www.home-assistant.io/voice_control/voice_remote_local_assistant/" target="_blank" rel="noopener noreferrer">voice assistant on homeassistant</a> using <a href="https://github.com/rhasspy/piper" target="_blank" rel="noopener noreferrer">piper</a>, and being impressed with the quality of the voices, I decided to give piper a go.</p>
<h2 id="new-setup">New Setup</h2>
<p>I am now using this simple <a href="https://github.com/nbr23/sayoutloud" target="_blank" rel="noopener noreferrer">Firefox Addon</a> I built to grab the highlighted text, and send it to <a href="https://github.com/nbr23/gopipertts" target="_blank" rel="noopener noreferrer">this API wrapper</a> I built around the piper binary.</p>
<h3 id="firefox-addon">Firefox Addon</h3>
<p><img src="images/sayoutloud-menu.png#smaller" alt=""></p>
<p>The addon simply adds a keyboard shortcut and context menu. When executed, the addon&rsquo;s background script sends the highlighted text to the TTS API through a <code>POST</code> request (along with the saved voice and speech speed preferences) to the <code>/api/stream</code> endpoint.
The API instantly returns a unique URL for the stream. The Addon then injects through the content script an audio player pointing to the returned URL into the current page. It then starts the playback.</p>
<p><img src="images/sayoutloud-player.png#smaller" alt=""></p>
<p>I initially tried relying only on the background script to play the speech, but audio playback would get cut after a few seconds (it seems background scripts are not meant to run for this long). Combining the background script with a content script turned out to be a better option with the UI allowing the user some control on the playback.</p>
<h3 id="piper-api-wrapper">Piper API Wrapper</h3>
<p>The <code>/api/stream</code> endpoint accepts <code>POST</code> requests with playback options (voice, speed, &hellip;) and text. It assigns a <code>UUID</code> to the request, saves it in memory, and returns a URL referencing this stream <code>UUID</code>.</p>
<p>When a stream <code>UUID</code> url is accessed, the options are fetched from the cache and passed to the <code>piper</code> binary to start generating the raw audio.</p>
<p>Streaming compatible WAV headers are sent back to the client. Raw audio chunks are sent back to the client as soon as <code>piper</code> starts generating them.</p>
<p>The <code>/api/stream</code> endpoint returning a unique stream URL trick allows us to easily play the audio in the browser using an <code>&lt;audio&gt;</code> tag (which performs a <code>GET</code> request).</p>
<p>I initially opted for an <a href="https://github.com/nbr23/wyoming-piper-http-bridge" target="_blank" rel="noopener noreferrer">HTTP to wyoming bridge</a>. This worked fine, but wyoming was an unnecessary overhead, and waited for the full text to be processed to start returning audio, resulting in long delays (especially running this on a raspi) before the TTS started its speaking.
Calling directly the piper binary from my wrapper allowed me to pass the <code>--output-raw</code> flag, and start getting audio data much earlier, streaming it back to the client without waiting for the entire potentially large text to be processed.</p>
<h2 id="final-thoughts">Final thoughts</h2>
<p>Even with streaming, there&rsquo;s still slightly more delay on speech start with piper compared to the local <code>xclip</code> to <code>espeak</code>. YMMV if you use a beefier box than me.</p>
<p>Overall I like the in-browser integration and the html interface on <a href="https://github.com/nbr23/gopipertts" target="_blank" rel="noopener noreferrer">gopipertts</a> if I need to quickly TTS from a non browser source.</p>
<p><img src="images/gopipertts-tartines.png#smaller" alt=""></p>
<p>The piper voices are a real improvement over what <code>espeak</code> provides&hellip; In English.</p>
<p>French is a different story&hellip; or perhaps this is what we sound like when saying &ldquo;tartine&rdquo;?</p>


<p>
<audio style="width: 100%;" controls="" src="audio/tartine.wav"></audio>
</p>

]]></content>
      </item>
    
      <item>
        <title>XSLT - Make your RSS feed look like your front page</title>
        <link>https://wip.tf/posts/rss-xsl/</link>
        <pubDate>Wed, 10 Jul 2024 22:18:24 -0400</pubDate>
        <guid>https://wip.tf/posts/rss-xsl/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/rss-xsl.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;After stumbling upon &lt;a href=&#34;https://darekkay.com/blog/rss-styling/&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;this post&lt;/a&gt; I decided to give &lt;a href=&#34;https://en.wikipedia.org/wiki/XSLT&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;XSLT&lt;/a&gt; a go.&lt;/p&gt;
&lt;h2 id=&#34;a-few-examples&#34;&gt;A few examples&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Making my blog&amp;rsquo;s RSS feed look like my front page: This site&amp;rsquo;s &lt;a href=&#34;https://wip.tf/posts/index.xml&#34;&gt;RSS feed&lt;/a&gt; visually mimics its &lt;a href=&#34;https://wip.tf/&#34;&gt;front page&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/nbr23/rss-banquet/blob/master/style/style.go&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;Prettifying&lt;/a&gt; the output of my &lt;a href=&#34;https://github.com/nbr23/rss-banquet/&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;feed generator&lt;/a&gt;.
&lt;img src=&#34;images/rss-banquet.png#center&#34; alt=&#34;&#34;&gt;&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/rss-xsl.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>After stumbling upon <a href="https://darekkay.com/blog/rss-styling/" target="_blank" rel="noopener noreferrer">this post</a> I decided to give <a href="https://en.wikipedia.org/wiki/XSLT" target="_blank" rel="noopener noreferrer">XSLT</a> a go.</p>
<h2 id="a-few-examples">A few examples</h2>
<ol>
<li>
<p>Making my blog&rsquo;s RSS feed look like my front page: This site&rsquo;s <a href="/posts/index.xml">RSS feed</a> visually mimics its <a href="/">front page</a>.</p>
</li>
<li>
<p><a href="https://github.com/nbr23/rss-banquet/blob/master/style/style.go" target="_blank" rel="noopener noreferrer">Prettifying</a> the output of my <a href="https://github.com/nbr23/rss-banquet/" target="_blank" rel="noopener noreferrer">feed generator</a>.
<img src="images/rss-banquet.png#center" alt=""></p>
</li>
<li>
<p><a href="https://github.com/nbr23/ydl-podcast/blob/master/ydl_podcast/templates/style.py" target="_blank" rel="noopener noreferrer">Beautiful Podcast Feed</a> with embedded episode player for my <a href="https://github.com/nbr23/ydl-podcast" target="_blank" rel="noopener noreferrer">youtube to podcast</a> project.</p>
</li>
</ol>
  <div class="image-container" style="display: flex; justify-content: space-between;">
    <a href="images/audio-podcast.png" style="max-width: 48%; margin-right: 2%;" target="_blank">
      <img src="images/audio-podcast.png"/>
    </a>
    <a href="images/video-podcast.png"  style="max-width: 48%;" target="_blank">
      <img src="images/video-podcast.png"/>
    </a>
  </div>
<h2 id="some-notes-after-playing-with-xslt-in-a-few-projects">Some notes after playing with XSLT in a few projects:</h2>
<ol>
<li>Styling your site&rsquo;s feed to match your site&rsquo;s appearance is trivial, but probably confusing when a user expects to have clicked on an RSS feed (thus the orange banner on this site&rsquo;s feed).</li>
<li>Poor support across browsers:</li>
</ol>
<ul>
<li>Safari appears to ignore <code>Content-type</code>, doesn&rsquo;t want to display the <code>XML</code> at all:</li>
</ul>
<p><img src="images/safari.png#center" alt=""></p>
<ul>
<li>Embedding the <code>xsl</code> file into the <code>xml</code> page is possible, and works on Firefox but not Chrome.</li>
</ul>
<p>On Firefox this can be achieve easily:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml-stylesheet type=&#34;text/xsl&#34; href=&#34;#rss-stylesheet&#34;?&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;rss&gt;</span>
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;xsl:stylesheet</span> <span style="color:#a6e22e">id=</span><span style="color:#e6db74">&#34;rss-stylesheet&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>    ...
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/xsl:stylesheet&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/rss&gt;</span>
</span></span></code></pre></div><!-- /noTTS -->
<p>But on Chrome it will only show a white page. Some background on why <a href="https://stackoverflow.com/questions/4558160/xsl-not-working-in-google-chrome/6251807#6251807" target="_blank" rel="noopener noreferrer">here</a>.</p>
<ul>
<li>Firefox does not support <code>disable-output-escaping</code>:</li>
</ul>
<p>This flag should prevent escaping of <code>&lt;&gt;</code> characters, allowing <code>HTML</code> from the feed&rsquo;s content to be rendered. Firefox does not seem to be supporting it, contrary to Chrome.</p>
<p>Note that if <code>disable-output-escaping</code> is <code>off</code>, <code>&lt;script&gt;</code> tags won&rsquo;t get escaped either and Chrome will execute their content!</p>

<h2 id="links-and-references">Links and References</h2>
<ul>
<li><a href="https://darekkay.com/blog/rss-styling/" target="_blank" rel="noopener noreferrer">[0]</a> - Style your RSS feed</li>
<li><a href="https://en.wikipedia.org/wiki/XSLT" target="_blank" rel="noopener noreferrer">[1]</a> - Wikipedia - XSLT</li>
<li><a href="https://github.com/nbr23/rss-banquet/" target="_blank" rel="noopener noreferrer">[2]</a> - RSS Banquet - A Modular Atom/RSS Feed Generator</li>
<li><a href="https://github.com/nbr23/ydl-podcast" target="_blank" rel="noopener noreferrer">[3]</a> - Ydl-Podcast -  A simple tool to generate Podcast like RSS feeds from youtube (or other youtube-dl supported services) channels, using youtube-dl</li>
<li><a href="/posts/index.xml">[4]</a> - This blog&rsquo;s RSS Feed</li>
<li><a href="/style.xsl">[5]</a> - This blog&rsquo;s RSS Feed&rsquo;s XSLT template/stylesheet (which you will note is an XML file and could be, itself, styled&hellip;)</li>
</ul>
]]></content>
      </item>
    
      <item>
        <title>RSS/Atom feed of docker image pushes to DockerHub</title>
        <link>https://wip.tf/posts/dockerrss/</link>
        <pubDate>Tue, 07 Mar 2023 21:16:44 -0500</pubDate>
        <guid>https://wip.tf/posts/dockerrss/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/dockerrss.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;p&gt;Some of my CI/CD deployments need to be triggered when a new docker image is pushed with a specific tag for a specific architecture, to stay up to date.&lt;/p&gt;
&lt;p&gt;A lot of these image updates can be dealt with dependabot/renovate, creating a PR with the new release, or with tools like &lt;a href=&#34;https://github.com/containrrr/watchtower&#34; target=&#34;_blank&#34; rel=&#34;noopener noreferrer&#34;&gt;watchtower&lt;/a&gt;.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/dockerrss.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><p>Some of my CI/CD deployments need to be triggered when a new docker image is pushed with a specific tag for a specific architecture, to stay up to date.</p>
<p>A lot of these image updates can be dealt with dependabot/renovate, creating a PR with the new release, or with tools like <a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer">watchtower</a>.</p>
<p>In more &ldquo;complex&rdquo; cases however, I need my Jenkins instance to be aware of the new image being pushed to perform actions before and after pulling the image and recreating the container.</p>
<p>I built <a href="https://github.com/nbr23/dockerRSS" target="_blank" rel="noopener noreferrer" class="github-link"><span>DockerRSS</span></a> (now <a href="https://github.com/nbr23/rss-banquet" target="_blank" rel="noopener noreferrer" class="github-link"><span>rss-banquet</span></a>) for this, polling the Dockerhub API and formatting the image pushes data into an RSS feed. Its best use for me is in generating a feed for a specific tag (typically <code>latest</code>) and filtering for a specific platform (<code>linux/arm64</code> in my case), to only get the image pushes I am interested in.</p>
<p>Using the <a href="https://plugins.jenkins.io/urltrigger/" target="_blank" rel="noopener noreferrer">UrlTrigger</a> Jenkins plugin, my pipelines get triggered by new pushes to DockerHub:</p>
<!-- noTTS -->
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-groovy" data-lang="groovy"><span style="display:flex;"><span>pipeline <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>    agent any
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">[...]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    triggers <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>        URLTrigger<span style="color:#f92672">(</span>
</span></span><span style="display:flex;"><span>            triggerLabel: <span style="color:#e6db74">&#39;my-agent&#39;</span><span style="color:#f92672">,</span>
</span></span><span style="display:flex;"><span>            labelRestriction: <span style="color:#66d9ef">true</span><span style="color:#f92672">,</span>
</span></span><span style="display:flex;"><span>            cronTabSpec: <span style="color:#e6db74">&#39;H/15 * * * *&#39;</span><span style="color:#f92672">,</span>
</span></span><span style="display:flex;"><span>            entries: <span style="color:#f92672">[</span>
</span></span><span style="display:flex;"><span>                URLTriggerEntry<span style="color:#f92672">(</span>
</span></span><span style="display:flex;"><span>                    url: <span style="color:#e6db74">&#39;https://banquet/dockerhub/nbr23/rss-banquet:server-latest?platform=linux/arm64&#39;</span><span style="color:#f92672">,</span>
</span></span><span style="display:flex;"><span>                    requestHeaders: <span style="color:#f92672">[</span>
</span></span><span style="display:flex;"><span>                        RequestHeader<span style="color:#f92672">(</span> headerName: <span style="color:#e6db74">&#34;Accept&#34;</span> <span style="color:#f92672">,</span> headerValue: <span style="color:#e6db74">&#34;application/atom+xml&#34;</span> <span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">],</span>
</span></span><span style="display:flex;"><span>                    contentTypes: <span style="color:#f92672">[</span>
</span></span><span style="display:flex;"><span>                        XMLContent<span style="color:#f92672">(</span>
</span></span><span style="display:flex;"><span>                            <span style="color:#f92672">[</span>
</span></span><span style="display:flex;"><span>                                XMLContentEntry<span style="color:#f92672">(</span> xPath: <span style="color:#e6db74">&#39;feed/entry[1]/id&#39;</span> <span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>                            <span style="color:#f92672">])</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">[...]</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></div><!-- /noTTS -->
<p>I also enjoy having the feed of DockerHub image pushes in the &lsquo;Releases&rsquo; section of my RSS Reader, at least for now. This allows me to keep track of interesting images or images that I maintain.</p>
<h2 id="2024-02-01-update">2024-02-01 Update:</h2>
<p>I have consolidated dockerRSS and other RSS/Atom feed tools into <a href="https://github.com/nbr23/rss-banquet" target="_blank" rel="noopener noreferrer" class="github-link"><span>rss-banquet</span></a>, and archived dockerRSS.</p>

]]></content>
      </item>
    
      <item>
        <title>Bistrobot: Orchestrating cat food dispensing with Raspberry Pies</title>
        <link>https://wip.tf/posts/bistrobot-orchestrating-cat-foot-dispensing-raspberry-pi/</link>
        <pubDate>Wed, 01 Feb 2023 22:52:55 -0500</pubDate>
        <guid>https://wip.tf/posts/bistrobot-orchestrating-cat-foot-dispensing-raspberry-pi/</guid>
        <description>&lt;div class=&#34;text-to-speech-container&#34;&gt;
    &lt;span class=&#34;text-to-speech-label&#34;&gt;Listen to this post:&lt;/span&gt;
    &lt;audio class=&#34;text-to-speech-audio&#34; controls&gt;
      &lt;source src=&#34;https://wip.tf/audio/tts/bistrobot-orchestrating-cat-foot-dispensing-raspberry-pi.wav&#34; type=&#34;audio/wav&#34;&gt;
    &lt;/audio&gt;
  &lt;/div&gt;
  &lt;script&gt;
    (function() {
      var a = document.createElement(&#39;audio&#39;);
      if (a.canPlayType &amp;&amp; a.canPlayType(&#39;audio/wav&#39;)) {
        var els = document.querySelectorAll(&#39;.text-to-speech-container&#39;);
        for (var i = 0; i &lt; els.length; i++) els[i].style.display = &#39;flex&#39;;
      }
    })();
  &lt;/script&gt;&lt;h2 id=&#34;problem&#34;&gt;Problem&lt;/h2&gt;
&lt;p&gt;I live with two cats:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cat 1&lt;/code&gt;, who has food anxiety and can meow for hours if his eating schedule is disrupted;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cat 2&lt;/code&gt;, who eats faster than his own shadow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To fix &lt;code&gt;cat 1&lt;/code&gt;&amp;rsquo;s food anxiety, we purchased two food dispensers online (each has a different branding but they are exactly the same product). These &amp;ldquo;robots&amp;rdquo; are simple, non-connected devices. Current time, meal sizes and schedules are manually configured through a &lt;a href=&#34;images/user_interface.jpg&#34;&gt;rudimentary interface&lt;/a&gt;.&lt;/p&gt;</description>
        <content type="html"><![CDATA[<div class="text-to-speech-container">
    <span class="text-to-speech-label">Listen to this post:</span>
    <audio class="text-to-speech-audio" controls>
      <source src="https://wip.tf/audio/tts/bistrobot-orchestrating-cat-foot-dispensing-raspberry-pi.wav" type="audio/wav">
    </audio>
  </div>
  <script>
    (function() {
      var a = document.createElement('audio');
      if (a.canPlayType && a.canPlayType('audio/wav')) {
        var els = document.querySelectorAll('.text-to-speech-container');
        for (var i = 0; i < els.length; i++) els[i].style.display = 'flex';
      }
    })();
  </script><h2 id="problem">Problem</h2>
<p>I live with two cats:</p>
<ul>
<li><code>cat 1</code>, who has food anxiety and can meow for hours if his eating schedule is disrupted;</li>
<li><code>cat 2</code>, who eats faster than his own shadow.</li>
</ul>
<p>To fix <code>cat 1</code>&rsquo;s food anxiety, we purchased two food dispensers online (each has a different branding but they are exactly the same product). These &ldquo;robots&rdquo; are simple, non-connected devices. Current time, meal sizes and schedules are manually configured through a <a href="images/user_interface.jpg">rudimentary interface</a>.</p>
<p>As a result of this manual setup, it is near impossible to have them perfectly synced, and even if achieved, they get out of sync quickly.</p>
<p>This leads us to the second problem:</p>
<p><img src="images/race_conditions.gif#center" alt="" title="race conditions: cat 2 eats both meals while the cat 1 watches, dumbfounded"></p>
<ul>
<li>If both robots dispense the scheduled meal even just a second apart, <code>cat 2</code> will gulp the first dispensed meal <strong>and</strong> rush to the second robot to eat the other meal, leaving <code>cat 1</code> dumbfounded and hungry (bringing us back to <a href="#problem">square 0</a>).</li>
</ul>
<h2 id="solution">Solution</h2>
<p>To perfectly synchronize the two robots, I gutted them and replaced the internals with a <strong><a href="https://www.raspberrypi.com/products/raspberry-pi-zero-w/" target="_blank" rel="noopener noreferrer">Raspberry Pi Zero W</a></strong> each.</p>
<p><img src="images/peek.jpg#smaller" alt="" title="cat 1 performing a sniff test"></p>
<p>Both robots are now <strong><code>ntp</code> synchronized,</strong> and they pilot the motor for the dispensing mechanism through <strong>GPIO</strong>.</p>
<p>Scheduling is done through a <code>crontab</code> on each Raspberry Pi, which I push using <code>ansible</code>.</p>
<p>This solution also permits remote one-shot meals for emergency through <code>ssh</code>/<code>ansible</code>.</p>
<p>3 GPIO pins are used:</p>
<ul>
<li>One <code>input</code>: the <a href="images/position_sensor.jpg">position trigger sensor</a> for the <a href="images/dispenser_wheel.jpg">dispenser wheel</a>;</li>
<li>Two <code>outputs</code>:
<ul>
<li>one for the transistor triggering the <a href="images/motor.jpg">motor</a> which rotates the dispenser wheel;</li>
<li>one for a buzzer to &ldquo;announce&rdquo; meals to the cats.</li>
</ul>
</li>
</ul>
<p><img src="images/final_product.jpg#smaller" alt="" title="bistrobot internals: the raspberry pi zero w, hooked up to the food dispenser's motor and sensors"></p>
<h2 id="side-effect">Side effect</h2>
<p>The python GPIO initialization appears to be driving a slight current during initialization, which is inaudible to me, but allows the cats to anticipate the actual buzzing: they now rush to their respective feeder <strong>before</strong> the buzzer even announces the upcoming meal.</p>
<h2 id="happy-ending">Happy Ending</h2>
<p>With both robots perfectly synced, <code>cat 1</code> and <code>cat 2</code> now enjoy their meals worry-free.</p>
<p><img src="images/happy_meal.jpg#smaller" alt="" title="cat 1 and 2 eating happily side by side"></p>

<h2 id="links">Links</h2>
<ul>
<li><a href="https://github.com/nbr23/bistrobot" target="_blank" rel="noopener noreferrer">GPIO controller Bistrobot&rsquo;s github repository</a></li>
<li><a href="https://pinout.xyz/" target="_blank" rel="noopener noreferrer">Handy RbPi GPIO pins guide</a></li>
</ul>
]]></content>
      </item>
    
  </channel>
</rss>
