<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://tuomasmelin.com/til/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tuomasmelin.com/" rel="alternate" type="text/html" /><updated>2026-10-22T13:40:24+03:00</updated><id>https://tuomasmelin.com/til/feed.xml</id><title type="html">Tuomas Melin</title><subtitle>Short notes on things I&apos;ve learned about data, cloud and tooling.</subtitle><author><name>Tuomas Melin</name></author><entry><title type="html">TIL: Push the orange tab first (Garage Door Project, Part 5/5)</title><link href="https://tuomasmelin.com/til/wiring-the-real-door/" rel="alternate" type="text/html" title="TIL: Push the orange tab first (Garage Door Project, Part 5/5)" /><published>2026-10-20T00:00:00+03:00</published><updated>2026-10-20T00:00:00+03:00</updated><id>https://tuomasmelin.com/til/wiring-the-real-door</id><content type="html" xml:base="https://tuomasmelin.com/til/wiring-the-real-door/"><![CDATA[<p><strong>Series:</strong> Opening my garage door with my phone — Part 5 of 5
<strong>Learned:</strong> 15 July – 20 October 2026</p>

<p>Everything worked on the desk. Home Assistant was running on the old laptop. Time to connect the real garage door opener, put a button on my phone, and make it reachable from outside the house.</p>

<h2 id="1-which-wire-goes-into-a-garage-door-opener">1. Which wire goes into a garage door opener?</h2>

<p>My opener has a 6-pin green <strong>spring-clamp terminal block</strong> with little orange tabs. My jumper wires were too thin to stay put.</p>

<p><strong>What I learned:</strong></p>

<ul>
  <li><strong>Jumper wires are for pin headers</strong>, not for spring terminals.</li>
  <li>Use <strong>solid-core</strong> copper, roughly <strong>0.5–0.75 mm²</strong>: alarm or doorbell cable, a pair from an old Cat5/Cat6 cable, or a single conductor stripped out of ordinary installation cable (e.g. MMJ, usually 1.5 mm², which still fits).</li>
  <li>Stranded wire wants a <strong>ferrule</strong> (pääteholkki) crimped on; loose strands splay out and slip.</li>
  <li>Strip about <strong>8–10 mm</strong>. Only copper should be inside the clamp, no insulation.</li>
</ul>

<h2 id="2-push-the-orange-tab-first">2. Push the orange tab first</h2>

<p>The morning after my first successful test, the wires had <strong>slipped half out</strong> of the opener.</p>

<p><strong>What I learned:</strong> on a spring-clamp terminal you <strong>press the orange tab down first</strong> (small screwdriver or pen tip), push the wire in, <em>then</em> release the tab. I had just been shoving wires into the holes, so the spring never clamped. A gentle tug test tells you immediately.</p>

<h2 id="3-find-the-right-terminals-and-test-safely">3. Find the right terminals, and test safely</h2>

<ul>
  <li><strong>The manual or the label on the opener is the authority</strong> for which two terminals are the push-button/impulse input. I got two different answers from an AI on this (first 3 &amp; 4, later 1 &amp; 2), so I found the opener’s PDF manual online. On my unit it’s <strong>terminals 1 and 2</strong>. Lesson: when an AI gives you two answers, the manufacturer’s documentation decides.</li>
  <li><strong>Mains voltage lives inside that housing.</strong> Unplug the opener before poking at its terminals.</li>
  <li><strong>Clear the door’s path</strong> before any test.</li>
  <li>To find the input safely: put a short wire in each candidate terminal and <strong>touch the free ends together for a split second</strong>, like a button press. Don’t leave them shorted, or the opener sees a button held down.</li>
  <li>Keep the remote in hand in case the door does something unexpected.</li>
</ul>

<h2 id="4-com-and-no-not-nc">4. COM and NO, not NC</h2>

<p>On the relay’s three screw terminals the <strong>middle one is COM</strong>. I had put a wire into <strong>NC</strong> (normally closed), which would mean “button held down” whenever the relay is <em>off</em>. Wires go to <strong>COM + NO</strong>. Polarity doesn’t matter on the opener side.</p>

<h2 id="5-home-assistant-a-button-and-a-health-check">5. Home Assistant: a button and a health check</h2>

<p>In <code class="language-plaintext highlighter-rouge">configuration.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">rest_command</span><span class="pi">:</span>
  <span class="na">toggle_garage</span><span class="pi">:</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://&lt;pi-ip&gt;:5000/api/toggle"</span>
    <span class="na">method</span><span class="pi">:</span> <span class="s2">"</span><span class="s">POST"</span>

<span class="na">rest</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">resource</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://&lt;pi-ip&gt;:5000/api/ping"</span>
    <span class="na">scan_interval</span><span class="pi">:</span> <span class="m">30</span>
    <span class="na">sensor</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Garage</span><span class="nv"> </span><span class="s">API</span><span class="nv"> </span><span class="s">Status"</span>
        <span class="na">unique_id</span><span class="pi">:</span> <span class="s">garage_api_status</span>
        <span class="na">value_template</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">value_json.status</span><span class="nv"> </span><span class="s">}}"</span>
</code></pre></div></div>

<p><strong>What I learned:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">found character '\t' that cannot start any token</code> → <strong>YAML forbids tabs</strong>. Spaces only.</li>
  <li>REST commands can be reloaded from <em>Developer Tools → YAML</em>; the <code class="language-plaintext highlighter-rouge">rest</code> sensor needs a restart.</li>
  <li>Test from <em>Developer Tools → Actions</em> with <code class="language-plaintext highlighter-rouge">rest_command.toggle_garage</code> before building UI.</li>
  <li>Dashboard: <em>Edit dashboard → Add card → Button</em>, tap action <strong>Perform action</strong> → <code class="language-plaintext highlighter-rouge">rest_command.toggle_garage</code>. (In my Finnish UI: <em>Muokkaa kojelautaa → Lisää kortti → Painike</em>.)</li>
  <li>For a <strong>desktop widget or phone shortcut</strong>, create an automation with a <strong>Webhook trigger</strong> that calls the same action. Anything that can send an HTTP POST to <code class="language-plaintext highlighter-rouge">/api/webhook/&lt;id&gt;</code> can now open the door.</li>
</ul>

<h2 id="6-months-later-my-pi-speaks-ancient-ssh">6. Months later: “my Pi speaks ancient SSH?”</h2>

<p>After the Pi had sat untouched for a while, SSH greeted me with a cascade of errors:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no matching key exchange method found ... diffie-hellman-group1-sha1
no matching host key type found. Their offer: ssh-dss
no matching cipher found. Their offer: aes128-cbc,...
Host key for 192.168.x.50 has changed
</code></pre></div></div>

<p>…and after forcing all the legacy options, <code class="language-plaintext highlighter-rouge">Connection closed by remote host</code>.</p>

<p><strong>What I learned:</strong> check the router’s DHCP client list <em>before</em> fighting SSH. The Pi’s <strong>IP had changed</strong>, and a completely different (and much older) device had taken over the old address. All those legacy crypto errors came from <em>that</em> device, not my Pi. With the Pi’s new IP, SSH just worked, no legacy flags needed. If SSH suddenly starts complaining about ancient algorithms, first ask: <em>am I even talking to the right machine?</em> Two fixes:</p>

<ul>
  <li><strong>DHCP reservation</strong> in the router: tie the Pi’s MAC address to a fixed IP.</li>
  <li>A health check like <code class="language-plaintext highlighter-rouge">/api/ping</code> + <code class="language-plaintext highlighter-rouge">systemctl status garage.service</code> tells you within seconds whether everything survived.</li>
</ul>

<h2 id="7-reaching-it-from-outside-tailscale">7. Reaching it from outside: Tailscale</h2>

<p>To open the door when I’m not on the home Wi-Fi, I went with <strong>Tailscale</strong> instead of opening ports on the router.</p>

<p><strong>What I learned:</strong></p>

<ul>
  <li>Guides that say <em>Settings → Add-ons → Tailscale</em> assume <strong>Home Assistant OS</strong>. With Home Assistant running in <strong>Docker</strong> there is no add-on store, so Tailscale goes on the <strong>host</strong> (the antiX laptop) instead.</li>
  <li>Private access (only devices logged into my tailnet) is the safer default. Funnel exposes Home Assistant publicly; behind a proxy, HA also needs <code class="language-plaintext highlighter-rouge">use_x_forwarded_for</code> and <code class="language-plaintext highlighter-rouge">trusted_proxies</code>.</li>
  <li>
    <p>On antiX, <code class="language-plaintext highlighter-rouge">tailscale up</code> failed with <code class="language-plaintext highlighter-rouge">failed to connect to local tailscaled; it doesn't appear to be running</code>. Install scripts assume systemd, which starts <code class="language-plaintext highlighter-rouge">tailscaled</code> for you; on antiX’s init it doesn’t happen. Start it yourself, then log in:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>tailscaled &amp;
<span class="nb">sudo </span>tailscale up
</code></pre></div>    </div>

    <p>To survive a reboot, <code class="language-plaintext highlighter-rouge">tailscaled</code> has to be added to antiX’s own service management.</p>
  </li>
  <li>In the Home Assistant Companion app, set the <strong>External URL</strong> to the laptop’s Tailscale address (<code class="language-plaintext highlighter-rouge">http://100.x.x.x:8123</code>).</li>
</ul>

<p><strong>And it worked.</strong> With <code class="language-plaintext highlighter-rouge">tailscaled</code> running on the laptop and the Tailscale VPN switched on in my phone, I could open Home Assistant from outside the house and tap the garage button. No ports opened on the router, and nothing exposed to the public internet. The one thing to remember is that the <strong>Tailscale VPN has to be connected on the phone</strong>; without it, the 100.x address goes nowhere.</p>

<h2 id="the-whole-journey-in-one-picture">The whole journey in one picture</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phone (HA app, via Tailscale)
  → antiX laptop running Home Assistant in Docker
    → REST call over Wi-Fi
      → Raspberry Pi 1 (Flask, behind a Wi-Fi extender's Ethernet port)
        → USB serial
          → Arduino Uno
            → Keyes relay (COM + NO)
              → garage door opener push-button terminals
</code></pre></div></div>

<h2 id="what-id-tell-myself-on-day-one">What I’d tell myself on day one</h2>

<ol>
  <li>A garage opener just needs two wires touched together. The electronics are the easy part.</li>
  <li><strong>Networking ate most of the time</strong>: old Pi, old BIOS, old SSH, changing IPs. Reserve IPs early.</li>
  <li>Test every link of the chain in isolation.</li>
  <li><code class="language-plaintext highlighter-rouge">dmesg</code>, <code class="language-plaintext highlighter-rouge">ssh -v</code> and the router’s client list answer most “why doesn’t it work” questions.</li>
  <li>Not everything is systemd. Not everything is Home Assistant OS. Read guides with that in mind.</li>
  <li>For remote access, a private VPN like Tailscale beats opening router ports. It took one command on the server and one toggle on the phone.</li>
  <li>Push the orange tab first.</li>
</ol>

<p>From “can I make my garage door talk to an API?” in early July to opening it from my phone over Tailscale in October: it works. 🚗🚪</p>]]></content><author><name>Tuomas Melin</name></author><category term="home-assistant" /><category term="wiring" /><category term="tailscale" /><category term="ssh" /><category term="til" /><summary type="html"><![CDATA[Connecting the real garage door opener, putting a button on my phone, and making it reachable from outside the house.]]></summary></entry><entry><title type="html">TIL: When the USB stick won’t boot, boot the installer from the hard drive (Garage Door Project, Part 4/5)</title><link href="https://tuomasmelin.com/til/reviving-an-old-laptop/" rel="alternate" type="text/html" title="TIL: When the USB stick won’t boot, boot the installer from the hard drive (Garage Door Project, Part 4/5)" /><published>2026-07-14T00:00:00+03:00</published><updated>2026-07-14T00:00:00+03:00</updated><id>https://tuomasmelin.com/til/reviving-an-old-laptop</id><content type="html" xml:base="https://tuomasmelin.com/til/reviving-an-old-laptop/"><![CDATA[<p><strong>Series:</strong> Opening my garage door with my phone — Part 4 of 5
<strong>Learned:</strong> 8–14 July 2026</p>

<p>I could now open the door with <code class="language-plaintext highlighter-rouge">curl</code>. For a phone-friendly UI I wanted <strong>Home Assistant</strong>, and the Pi 1 is far too weak for it. What I <em>did</em> have was an old Fujitsu Siemens laptop (1.8 GHz CPU, 2 GB RAM) dual-booting Windows Vista and Lubuntu 18.04. This post is about turning it into a server, which included a genuinely clever workaround.</p>

<h2 id="1-fixing-wi-fi-on-an-old-lubuntu">1. Fixing Wi-Fi on an old Lubuntu</h2>

<p>The Wi-Fi card wouldn’t connect: <code class="language-plaintext highlighter-rouge">nmcli device</code> showed it as <code class="language-plaintext highlighter-rouge">unavailable</code>, and the GUI said <strong>“device not managed”</strong>.</p>

<p>Debugging path:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lspci <span class="nt">-knn</span> | <span class="nb">grep</span> <span class="nt">-A3</span> Network   <span class="c"># Atheros AR242x, but no "Kernel driver in use"</span>
<span class="nb">sudo </span>modprobe ath5k             <span class="c"># load the driver</span>
dmesg | <span class="nb">grep</span> <span class="nt">-i</span> ath             <span class="c"># driver happy, card named wls1</span>
rfkill list                     <span class="c"># nothing blocked</span>
</code></pre></div></div>

<p><strong>What I learned:</strong> the driver was fine; <strong>Netplan</strong> was configured so that NetworkManager didn’t manage the card. The fix was a three-line file in <code class="language-plaintext highlighter-rouge">/etc/netplan/</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">network</span><span class="pi">:</span>
  <span class="na">version</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">renderer</span><span class="pi">:</span> <span class="s">NetworkManager</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>netplan apply
</code></pre></div></div>

<p>…and Wi-Fi networks appeared.</p>

<h2 id="2-lubuntu-1804-is-end-of-life-so-pick-a-server-os">2. Lubuntu 18.04 is end-of-life, so pick a server OS</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">cat /etc/os-release</code> showed <strong>18.04.5</strong>, out of support since April 2021 (Lubuntu flavours only got three years).</li>
  <li>For an always-on box, a <strong>headless</strong> (no desktop) install leaves far more RAM for the actual work.</li>
  <li><strong>2 GB RAM is enough for Home Assistant</strong> if you skip heavy extras (video processing, big databases).</li>
  <li>On low-end hardware, run <strong>Home Assistant in Docker</strong> rather than Home Assistant OS in a virtual machine.</li>
  <li>An <strong>SSD</strong> instead of an old spinning disk is the cheapest speed-up you can buy.</li>
</ul>

<p>I wiped Windows Vista too. Nothing on it was needed.</p>

<h2 id="3-the-usb-boot-wall">3. The USB boot wall</h2>

<p>This is where things went sideways. Every installer stick failed:</p>

<table>
  <thead>
    <tr>
      <th>Attempt</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Debian netinst via Startup Disk Creator</td>
      <td>“This is not a bootable disk”</td>
    </tr>
    <tr>
      <td>Debian, written with <code class="language-plaintext highlighter-rouge">dd</code></td>
      <td>same</td>
    </tr>
    <tr>
      <td>Full Debian DVD, different stick</td>
      <td>same</td>
    </tr>
    <tr>
      <td><strong>Ventoy</strong> (MBR) + ISO</td>
      <td>Black screen, blinking cursor</td>
    </tr>
    <tr>
      <td>Ventoy GPT for UEFI</td>
      <td>Not even listed</td>
    </tr>
    <tr>
      <td>antiX via Ventoy, then via <code class="language-plaintext highlighter-rouge">dd</code></td>
      <td>Black screen, blinking cursor</td>
    </tr>
  </tbody>
</table>

<p>Things I learned along the way:</p>

<ul>
  <li>The BIOS was an old <strong>PhoenixBIOS</strong>: no UEFI, no Secure Boot, no CSM toggle. It only boots <strong>MBR/legacy</strong> media, so GPT/UEFI sticks are pointless.</li>
  <li>Ventoy’s data partition is <strong>exFAT</strong>; an old Lubuntu needs <code class="language-plaintext highlighter-rouge">sudo apt install exfat-fuse exfat-utils</code> before you can copy ISOs onto it.</li>
  <li><code class="language-plaintext highlighter-rouge">umount</code>, not <code class="language-plaintext highlighter-rouge">unmount</code>. (Yes, really.)</li>
</ul>

<h2 id="4-the-workaround-boot-the-installer-from-the-hard-drive">4. The workaround: boot the installer from the hard drive</h2>

<p>The machine booted its <em>existing</em> GRUB from the hard drive perfectly. So instead of fighting the BIOS, I let GRUB start the antiX live system <strong>straight from the disk</strong>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Mount the ISO and copy the kernel + initrd into /boot</span>
<span class="nb">sudo mkdir</span> <span class="nt">-p</span> /mnt/iso
<span class="nb">sudo </span>mount <span class="nt">-o</span> loop ~/Downloads/antiX-<span class="k">*</span>.iso /mnt/iso
<span class="nb">sudo cp</span> /mnt/iso/antix/vmlinuz   /boot/vmlinuz-antix
<span class="nb">sudo cp</span> /mnt/iso/antix/initrd.gz /boot/initrd-antix.gz

<span class="c"># 2. Copy the live filesystem folder to the root of the disk</span>
<span class="nb">sudo cp</span> <span class="nt">-r</span> /mnt/iso/antix /
<span class="nb">sudo </span>umount /mnt/iso
</code></pre></div></div>

<p>Then add a menu entry to <code class="language-plaintext highlighter-rouge">/etc/grub.d/40_custom</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>menuentry "Install antiX from hard disk" {
    insmod ext2
    set root=(hd0,5)
    linux  /boot/vmlinuz-antix from=hd antiX=all
    initrd /boot/initrd-antix.gz
}
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>update-grub
</code></pre></div></div>

<p>Each error on the way taught something:</p>

<table>
  <thead>
    <tr>
      <th>Error</th>
      <th>Meaning / fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">no such partition</code> / <code class="language-plaintext highlighter-rouge">you need to load the kernel first</code></td>
      <td>GRUB couldn’t find the partition. <code class="language-plaintext highlighter-rouge">df /boot</code> said <code class="language-plaintext highlighter-rouge">/dev/sda5</code>; with <code class="language-plaintext highlighter-rouge">insmod ext2</code>, <code class="language-plaintext highlighter-rouge">(hd0,5)</code> worked.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">file /initrd-antix.gz not found</code> → kernel panic</td>
      <td>The <code class="language-plaintext highlighter-rouge">initrd</code> line was missing the <code class="language-plaintext highlighter-rouge">/boot/</code> prefix.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Could not find file antix/linuxfs</code></td>
      <td>The live system needs the whole <code class="language-plaintext highlighter-rouge">antix/</code> folder copied to the disk root.</td>
    </tr>
  </tbody>
</table>

<p>Then: <strong>antiX booted to a desktop.</strong> 🎉</p>

<h2 id="5-installing-antix-from-a-live-system-on-the-same-disk">5. Installing antiX from a live system on the same disk</h2>

<p>The graphical installer showed an <strong>empty partition list</strong>, because the live system was running from <code class="language-plaintext highlighter-rouge">/dev/sda5</code> and the installer locked the disk.</p>

<p>What worked:</p>

<ol>
  <li>In <strong>GParted</strong>: delete the Windows partitions, create one <strong>ext4</strong> partition in the free space, and click the green ✓ to apply.</li>
  <li>Skip the graphical installer and run <strong><code class="language-plaintext highlighter-rouge">cli-installer</code></strong> in a terminal.</li>
  <li>Answer: no repartitioning, root = the new partition, no separate <code class="language-plaintext highlighter-rouge">/home</code>, and install <strong>GRUB to the MBR</strong> (legacy BIOS).</li>
</ol>

<h2 id="6-docker-on-a-system-without-systemd">6. Docker on a system without systemd</h2>

<p>antiX doesn’t use systemd, which tripped up nearly every guide:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">docker.io</code> from the repos didn’t give me a working <code class="language-plaintext highlighter-rouge">docker</code> command. Following <strong>Docker’s official Debian instructions</strong> did.</li>
  <li><code class="language-plaintext highlighter-rouge">sudo service docker start</code> → <code class="language-plaintext highlighter-rouge">docker: unrecognized service</code>. Try <code class="language-plaintext highlighter-rouge">sudo /etc/init.d/docker start</code>, or start <code class="language-plaintext highlighter-rouge">dockerd</code> directly.</li>
  <li><code class="language-plaintext highlighter-rouge">sudo docker run hello-world</code> is the “is it alive?” test.</li>
  <li><code class="language-plaintext highlighter-rouge">docker: invalid reference format</code> = a <strong>copy-pasted multi-line command</strong> with broken <code class="language-plaintext highlighter-rouge">\</code> line breaks. Put it on one line:</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="nt">--name</span> homeassistant <span class="nt">--privileged</span> <span class="nt">--restart</span><span class="o">=</span>unless-stopped <span class="se">\</span>
  <span class="nt">-m</span> 1G <span class="nt">--memory-swap</span> 1G <span class="se">\</span>
  <span class="nt">-v</span> ~/homeassistant:/config <span class="se">\</span>
  <span class="nt">--network</span><span class="o">=</span>host <span class="se">\</span>
  ghcr.io/home-assistant/home-assistant:stable
</code></pre></div></div>

<p>More things I learned:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-m 1G --memory-swap 1G</code> caps Home Assistant’s memory so it can’t starve the whole laptop. Changing it means <code class="language-plaintext highlighter-rouge">docker stop</code>, <code class="language-plaintext highlighter-rouge">docker rm</code> and <code class="language-plaintext highlighter-rouge">docker run</code> again; your config survives in <code class="language-plaintext highlighter-rouge">~/homeassistant</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">--restart=unless-stopped</code> brings the container back <strong>if the Docker daemon starts at boot</strong>. On a SysV-style init that’s <code class="language-plaintext highlighter-rouge">sudo update-rc.d docker defaults</code>.</li>
  <li>Files created by the container are owned by root: <code class="language-plaintext highlighter-rouge">sudo chown -R $USER:$USER ~/homeassistant</code> fixes “Permission denied” when editing <code class="language-plaintext highlighter-rouge">configuration.yaml</code>.</li>
  <li>Home Assistant felt slow because I used it <strong>in a browser on the laptop itself</strong>. Use it from your phone or another computer and let the laptop just serve.</li>
  <li>The lightest antiX desktops are IceWM (the default), Fluxbox and JWM.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>“Unmanaged” Wi-Fi on Ubuntu-family systems: check Netplan’s <code class="language-plaintext highlighter-rouge">renderer</code>.</li>
  <li>Old BIOS + modern boot media = pain. If the machine can boot its own disk, <strong>let GRUB boot the installer</strong> from it.</li>
  <li>Not every distro uses systemd. Look for <code class="language-plaintext highlighter-rouge">service</code>, <code class="language-plaintext highlighter-rouge">/etc/init.d</code> and <code class="language-plaintext highlighter-rouge">update-rc.d</code>.</li>
  <li>Run Home Assistant in Docker with a memory cap on small machines, and use it from another device.</li>
</ul>

<p><strong>Next up:</strong> finally connecting the real garage door, and reaching it from outside the house.</p>]]></content><author><name>Tuomas Melin</name></author><category term="linux" /><category term="antix" /><category term="grub" /><category term="docker" /><category term="home-assistant" /><category term="til" /><summary type="html"><![CDATA[For a phone-friendly UI I wanted Home Assistant, and the Pi 1 is far too weak for it. Turning an old laptop into a server included a genuinely clever workaround.]]></summary></entry><entry><title type="html">TIL: Turning an HTTP request into a relay click (Garage Door Project, Part 3/5)</title><link href="https://tuomasmelin.com/til/the-pi-bridge/" rel="alternate" type="text/html" title="TIL: Turning an HTTP request into a relay click (Garage Door Project, Part 3/5)" /><published>2026-07-10T00:00:00+03:00</published><updated>2026-07-10T00:00:00+03:00</updated><id>https://tuomasmelin.com/til/the-pi-bridge</id><content type="html" xml:base="https://tuomasmelin.com/til/the-pi-bridge/"><![CDATA[<p><strong>Series:</strong> Opening my garage door with my phone — Part 3 of 5
<strong>Learned:</strong> July 2026</p>

<p>With the Arduino clicking on command (Part 1) and the Raspberry Pi finally reachable (Part 2), it was time to connect them:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Laptop/phone → Wi-Fi → extender → Pi (Flask API) → USB serial → Arduino → relay
</code></pre></div></div>

<h2 id="1-find-the-arduino-on-the-pi">1. Find the Arduino on the Pi</h2>

<p>Plug the Arduino into the Pi with a USB <strong>data</strong> cable, then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> /dev/ttyUSB<span class="k">*</span> /dev/ttyACM<span class="k">*</span>   <span class="c"># clones often show up as ttyUSB0</span>
dmesg | <span class="nb">tail</span> <span class="nt">-n</span> 20             <span class="c"># look for "ch341-uart ... attached to ttyUSB0"</span>
lsusb                          <span class="c"># is it on the bus at all?</span>
</code></pre></div></div>

<p>Let your user access serial ports without sudo (log out/in or reboot afterwards):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>usermod <span class="nt">-a</span> <span class="nt">-G</span> dialout <span class="nv">$USER</span>
</code></pre></div></div>

<h2 id="2-test-the-serial-link-by-hand">2. Test the serial link by hand</h2>

<p>Before writing any Python, talk to the Arduino directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install </span>screen
screen /dev/ttyUSB0 9600
<span class="c"># type o → relay clicks</span>
<span class="c"># exit with Ctrl+A, then \</span>
</code></pre></div></div>

<h2 id="3-the-bridge-flask--pyserial">3. The bridge: Flask + pyserial</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">time</span>
<span class="kn">import</span> <span class="nn">serial</span>
<span class="kn">from</span> <span class="nn">flask</span> <span class="kn">import</span> <span class="n">Flask</span><span class="p">,</span> <span class="n">jsonify</span>

<span class="n">SERIAL_PORT</span> <span class="o">=</span> <span class="s">"/dev/ttyUSB0"</span>
<span class="n">ser</span> <span class="o">=</span> <span class="n">serial</span><span class="p">.</span><span class="n">Serial</span><span class="p">(</span><span class="n">SERIAL_PORT</span><span class="p">,</span> <span class="mi">9600</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
<span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>  <span class="c1"># the Arduino resets when the port opens
</span>
<span class="n">app</span> <span class="o">=</span> <span class="n">Flask</span><span class="p">(</span><span class="n">__name__</span><span class="p">)</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="s">"/api/toggle"</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s">"POST"</span><span class="p">])</span>
<span class="k">def</span> <span class="nf">toggle</span><span class="p">():</span>
    <span class="n">ser</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="sa">b</span><span class="s">"o"</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">jsonify</span><span class="p">({</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"triggered"</span><span class="p">})</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="s">"/api/ping"</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s">"GET"</span><span class="p">])</span>
<span class="k">def</span> <span class="nf">ping</span><span class="p">():</span>
    <span class="k">return</span> <span class="n">jsonify</span><span class="p">({</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"ok"</span><span class="p">})</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">app</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="s">"0.0.0.0"</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">5000</span><span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">/api/ping</code> endpoint turned out to be very handy later for health checks in Home Assistant.</p>

<h2 id="4-make-it-a-service">4. Make it a service</h2>

<p>Running the script by hand doesn’t survive a reboot, so it became a systemd unit at <code class="language-plaintext highlighter-rouge">/etc/systemd/system/garage.service</code>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Unit]</span>
<span class="py">Description</span><span class="p">=</span><span class="s">Garage Door API</span>
<span class="py">After</span><span class="p">=</span><span class="s">network.target</span>

<span class="nn">[Service]</span>
<span class="py">ExecStart</span><span class="p">=</span><span class="s">/usr/bin/python3 /home/&lt;user&gt;/garage/garage_server.py</span>
<span class="py">WorkingDirectory</span><span class="p">=</span><span class="s">/home/&lt;user&gt;/garage</span>
<span class="py">Restart</span><span class="p">=</span><span class="s">always</span>
<span class="py">User</span><span class="p">=</span><span class="s">&lt;user&gt;</span>

<span class="nn">[Install]</span>
<span class="py">WantedBy</span><span class="p">=</span><span class="s">multi-user.target</span>
</code></pre></div></div>

<p>My first <code class="language-plaintext highlighter-rouge">systemctl start</code> failed with <code class="language-plaintext highlighter-rouge">Unit garage.service not found</code>.</p>

<p><strong>What I learned:</strong> systemd doesn’t watch that folder. After creating or editing a unit file you must run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl <span class="nb">enable</span> <span class="nt">--now</span> garage.service
<span class="nb">sudo </span>systemctl status garage.service
<span class="nb">sudo </span>journalctl <span class="nt">-u</span> garage.service <span class="nt">-n</span> 50 <span class="nt">--no-pager</span>   <span class="c"># when something's off</span>
</code></pre></div></div>

<h2 id="5-the-serial-port-can-only-have-one-owner">5. The serial port can only have one owner</h2>

<p>The service started, but couldn’t talk to the Arduino. The culprit was my <strong>forgotten <code class="language-plaintext highlighter-rouge">screen</code> session</strong> from step 2, still holding <code class="language-plaintext highlighter-rouge">/dev/ttyUSB0</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>screen <span class="nt">-ls</span>                        <span class="c"># list sessions</span>
screen <span class="nt">-X</span> <span class="nt">-S</span> &lt;session_id&gt; quit    <span class="c"># kill one</span>
<span class="nb">sudo </span>fuser /dev/ttyUSB0           <span class="c"># who is using the port?</span>
<span class="nb">sudo </span>fuser <span class="nt">-k</span> /dev/ttyUSB0        <span class="c"># make them stop</span>
<span class="nb">sudo </span>systemctl restart garage.service
</code></pre></div></div>

<blockquote>
  <p><strong>TIL:</strong> a serial port is exclusive. Serial Monitor, <code class="language-plaintext highlighter-rouge">screen</code>, a stray Node script: any of them will silently block your service.</p>
</blockquote>

<h2 id="6-the-first-real-success">6. The first real success</h2>

<p>From my laptop:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://&lt;pi-ip&gt;:5000/api/toggle
</code></pre></div></div>

<p><strong>Click.</strong> The whole chain worked end to end.</p>

<h2 id="7-a-word-on-security">7. A word on security</h2>

<p>At this point anyone on my Wi-Fi who knows the Pi’s IP can “press the button”. Easy first improvements:</p>

<ul>
  <li>Require a secret header (e.g. <code class="language-plaintext highlighter-rouge">X-API-KEY</code>) and return <code class="language-plaintext highlighter-rouge">401</code> without it.</li>
  <li><strong>Don’t open router ports</strong> for remote access; use a private tunnel instead (that’s Part 5).</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Test each link of the chain on its own: Serial Monitor → <code class="language-plaintext highlighter-rouge">screen</code> → <code class="language-plaintext highlighter-rouge">curl</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">daemon-reload</code> after every unit file change.</li>
  <li>Only one process can own a serial port.</li>
  <li>Add a cheap <code class="language-plaintext highlighter-rouge">/ping</code> endpoint from day one.</li>
</ul>

<p><strong>Next up:</strong> I needed a proper home for Home Assistant, which meant reviving a very old laptop.</p>]]></content><author><name>Tuomas Melin</name></author><category term="raspberry-pi" /><category term="python" /><category term="flask" /><category term="systemd" /><category term="serial" /><category term="til" /><summary type="html"><![CDATA[With the Arduino clicking on command and the Raspberry Pi finally reachable, it was time to connect them with a small Flask API.]]></summary></entry><entry><title type="html">TIL: Getting a first-gen Raspberry Pi online is the hard part (Garage Door Project, Part 2/5)</title><link href="https://tuomasmelin.com/til/getting-an-old-pi-online/" rel="alternate" type="text/html" title="TIL: Getting a first-gen Raspberry Pi online is the hard part (Garage Door Project, Part 2/5)" /><published>2026-07-07T00:00:00+03:00</published><updated>2026-07-07T00:00:00+03:00</updated><id>https://tuomasmelin.com/til/getting-an-old-pi-online</id><content type="html" xml:base="https://tuomasmelin.com/til/getting-an-old-pi-online/"><![CDATA[<p><strong>Series:</strong> Opening my garage door with my phone — Part 2 of 5
<strong>Learned:</strong> 4–7 July 2026</p>

<p>In Part 1 I decided a Raspberry Pi would host the API and talk to the Arduino over USB. The Pi I had is a <strong>first-generation Model B</strong>: no Wi-Fi, two USB ports, and a very old Raspbian on the SD card. Getting it reachable on my network turned into the longest chapter of the whole project.</p>

<h2 id="1-attempt-one-a-usb-wi-fi-dongle">1. Attempt one: a USB Wi-Fi dongle</h2>

<p><strong>Buying advice I got (and would repeat):</strong></p>

<ul>
  <li>A Pi 1 needs a <strong>USB Wi-Fi adapter</strong>. Look for “Linux compatible” or “Raspberry Pi compatible” and a chipset with in-kernel drivers.</li>
  <li>Prefer a small <strong>2.4 GHz “nano”</strong> adapter. The Pi 1 can’t use 5 GHz speeds anyway and has very little power to spare on its USB ports.</li>
  <li>Use a <strong>good power supply</strong> and, ideally, a <strong>powered USB hub</strong>.</li>
</ul>

<p><strong>What actually happened:</strong></p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">wpa_cli -i wlan0 reconfigure</code> failed with <code class="language-plaintext highlighter-rouge">Failed to connect to wpa_supplicant - wpa_ctrl_open: No such file or directory</code>.</li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">sudo raspi-config</code> had <strong>no network options at all</strong>. That’s the tell that you’re on a very old Raspbian (Wheezy/Jessie era). Back then Wi-Fi was configured in <code class="language-plaintext highlighter-rouge">/etc/network/interfaces</code>:</p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>auto wlan0
allow-hotplug wlan0
iface wlan0 inet dhcp
    wpa-ssid "YOUR_SSID"
    wpa-psk "YOUR_PASSWORD"
</code></pre></div>    </div>
  </li>
  <li>
    <p>Still nothing, so I checked whether the hardware was even seen:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsusb               <span class="c"># only the internal hub + Ethernet chip, no dongle</span>
dmesg | <span class="nb">tail</span> <span class="nt">-n</span> 20  <span class="c"># the real story</span>
</code></pre></div>    </div>
  </li>
</ol>

<p><strong>What I learned from <code class="language-plaintext highlighter-rouge">dmesg</code>:</strong> the dongle <em>was</em> detected for a moment (a Realtek chip, driver <code class="language-plaintext highlighter-rouge">r8188eu</code>), then two things went wrong:</p>

<ul>
  <li>the firmware file <code class="language-plaintext highlighter-rouge">rtlwifi/rtl8188eufw.bin</code> was <strong>missing</strong> (<code class="language-plaintext highlighter-rouge">error -2</code>), and</li>
  <li>right after, <strong><code class="language-plaintext highlighter-rouge">USB disconnect</code></strong>, most likely the Pi 1’s USB port browning out when the radio powered up.</li>
</ul>

<p>So: missing firmware <em>and</em> not enough power. Two problems that look like one.</p>

<blockquote>
  <p><strong>TIL:</strong> <code class="language-plaintext highlighter-rouge">lsusb</code> tells you if the hardware is there <em>now</em>. <code class="language-plaintext highlighter-rouge">dmesg</code> tells you what happened when you plugged it in. Read <code class="language-plaintext highlighter-rouge">dmesg</code> first.</p>
</blockquote>

<h2 id="2-attempt-two-give-the-pi-a-cable-instead">2. Attempt two: give the Pi a cable instead</h2>

<p>Instead of fighting the dongle, I put a <strong>Wi-Fi range extender with an Ethernet port</strong> next to the Pi. The extender joins the home Wi-Fi and the Pi just sees a normal wired network.</p>

<p>Setting up a TP-Link extender, three ways:</p>

<ol>
  <li><strong>WPS</strong>: press WPS on the router, then within two minutes press WPS/RE on the extender.</li>
  <li><strong>TP-Link Tether app</strong></li>
  <li><strong>Browser</strong>: join the extender’s own Wi-Fi and open <code class="language-plaintext highlighter-rouge">http://tplinkrepeater.net</code> (or <code class="language-plaintext highlighter-rouge">192.168.0.254</code>).</li>
</ol>

<p>Configure it next to the router, then move it roughly halfway to where you need the signal.</p>

<h2 id="3-the-lights-are-on-but-nobodys-home">3. “The lights are on but nobody’s home”</h2>

<p>The Ethernet LEDs on the Pi were lit, but the Pi never got an IP address. Things worth checking:</p>

<ul>
  <li>The extender must act as a <strong>bridge</strong>: the <strong>main router</strong> hands out IP addresses, not the extender.</li>
  <li>Compare subnets: if the Pi and your laptop aren’t in the same <code class="language-plaintext highlighter-rouge">192.168.x.</code> range, they can’t talk.</li>
  <li>Plug the Pi <strong>directly into the router</strong> for one minute. If it gets an IP there, the extender is the problem; if not, the Pi is.</li>
</ul>

<p>In the end the quickest fix was a <strong>fresh SD card image</strong>.</p>

<h2 id="4-fresh-install-still-connection-refused">4. Fresh install, still “Connection refused”</h2>

<p>I flashed a new image with <strong>Raspberry Pi Imager</strong> and used its settings (gear icon) to enable SSH and create my own user. The Pi booted and showed its IP. SSH said <code class="language-plaintext highlighter-rouge">Connection refused</code>.</p>

<p><strong>What I learned about debugging SSH, in order:</strong></p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">ping</code> the Pi.</strong> If ping works, the network path is fine and the problem is the SSH service or the address.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ssh -v user@ip</code></strong> shows where the handshake stops.
    <ul>
      <li><em>Connection refused</em> → nothing is listening on port 22 at that address.</li>
      <li><em>Permission denied</em> → SSH is fine, credentials aren’t.</li>
    </ul>
  </li>
  <li><strong>Check the IP really belongs to the Pi</strong> in the router or extender client list. After re-imaging, it may have a new one.</li>
  <li>
    <p><strong>Clear the old fingerprint</strong> if you’ve connected to that IP before:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen <span class="nt">-R</span> 192.168.x.x
</code></pre></div>    </div>
  </li>
  <li><strong>Give the first boot a few minutes</strong>, because it generates host keys.</li>
  <li>If you can’t get in at all: put an <strong>empty file named <code class="language-plaintext highlighter-rouge">ssh</code></strong> in the boot partition of the SD card, or borrow a keyboard and run <code class="language-plaintext highlighter-rouge">sudo systemctl enable --now ssh</code>.</li>
</ol>

<p>Also: you <strong>can’t SSH to a MAC address</strong>. You <em>can</em> use the MAC address in your router to find, or permanently reserve, the Pi’s IP.</p>

<p>Eventually I was in. 🎉</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>On a Pi 1, USB Wi-Fi is a power <em>and</em> driver problem. A powered hub plus the right firmware package, or skip it entirely.</li>
  <li>An extender with an Ethernet port is a neat way to bring a wired-only device onto Wi-Fi.</li>
  <li><code class="language-plaintext highlighter-rouge">dmesg</code> &gt; guessing.</li>
  <li>For SSH: ping → <code class="language-plaintext highlighter-rouge">ssh -v</code> → confirm the IP → clear <code class="language-plaintext highlighter-rouge">known_hosts</code>.</li>
</ul>

<p><strong>Next up:</strong> the Python bridge that turns an HTTP request into a relay click.</p>]]></content><author><name>Tuomas Melin</name></author><category term="raspberry-pi" /><category term="wifi" /><category term="networking" /><category term="ssh" /><category term="til" /><summary type="html"><![CDATA[My Pi is a first-generation Model B: no Wi-Fi, two USB ports and a very old Raspbian. Getting it onto my network turned into the longest chapter of the project.]]></summary></entry><entry><title type="html">TIL: A garage door button is just two wires touching (Garage Door Project, Part 1/5)</title><link href="https://tuomasmelin.com/til/from-remote-to-relay/" rel="alternate" type="text/html" title="TIL: A garage door button is just two wires touching (Garage Door Project, Part 1/5)" /><published>2026-07-02T00:00:00+03:00</published><updated>2026-07-02T00:00:00+03:00</updated><id>https://tuomasmelin.com/til/from-remote-to-relay</id><content type="html" xml:base="https://tuomasmelin.com/til/from-remote-to-relay/"><![CDATA[<p><strong>Series:</strong> Opening my garage door with my phone — Part 1 of 5
<strong>Learned:</strong> 2–4 July 2026</p>

<p>I can open my garage door with a remote. I wanted to open it with an API call instead. I know how to write the software, but I had no idea what electronics sit between “HTTP request” and “door moves”. This post covers the first few days: figuring out the hardware, digging through an old microcontroller kit, and getting a relay to click on my desk.</p>

<h2 id="1-the-opener-only-needs-a-dry-contact">1. The opener only needs a “dry contact”</h2>

<p>The wall button on a garage door opener doesn’t send any power anywhere. It just <strong>shorts two low-voltage terminals together for a moment</strong>. That’s called a <em>dry contact</em>.</p>

<p>So the whole “smart” part boils down to: something that can briefly connect two wires when told to. That something is a <strong>relay</strong>.</p>

<ul>
  <li>Wire the relay’s <strong>COM</strong> (common) and <strong>NO</strong> (normally open) terminals <em>in parallel</em> with the existing wall button.</li>
  <li>When the relay closes for ~0.5 s, the opener thinks someone pressed the button.</li>
  <li>The existing remotes and wall button keep working. Nothing is replaced, only added.</li>
</ul>

<p>Optional extra I’ve parked for later: a <strong>magnetic reed switch</strong> on the door frame to know whether the door is actually open or closed.</p>

<h2 id="2-inventory-what-you-already-have">2. Inventory what you already have</h2>

<p>I had an old kit from Hobby Components lying around. Relevant finds:</p>

<ul>
  <li><strong>Arduino Uno</strong> (clone)</li>
  <li><strong>Keyes 5V relay module</strong> with three pins: <code class="language-plaintext highlighter-rouge">S</code> (signal), <code class="language-plaintext highlighter-rouge">+</code> (5V) and <code class="language-plaintext highlighter-rouge">-</code> (GND), plus three screw terminals: NC, COM, NO</li>
  <li><strong>Arduino Prototype Shield v5</strong>, a board that stacks on the Uno for soldering your own circuit onto</li>
</ul>

<p><strong>What I learned:</strong> the Uno has <strong>no networking at all</strong>. To expose an API you need <em>something</em> with Wi-Fi or Ethernet. The options were:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ESP8266 (ESP-01) module next to the Uno</td>
      <td>Runs at 3.3V, talks to the Uno over serial with AT commands. Fiddly.</td>
    </tr>
    <tr>
      <td>Replace the Uno with an ESP32 / Wemos D1</td>
      <td>Built-in Wi-Fi, can host the API itself. Logic is 3.3V, so a 5V relay may need a level shifter.</td>
    </tr>
    <tr>
      <td><strong>Uno + Raspberry Pi over USB</strong></td>
      <td>Pi runs the API in a “real” language, Uno only flips the relay.</td>
    </tr>
  </tbody>
</table>

<p>I chose the last one: an old Raspberry Pi as the <strong>brain</strong> and the Arduino as the <strong>muscle</strong>. (Spoiler: the Pi being old became its own adventure, see Part 2.)</p>

<h2 id="3-wiring-the-relay-for-a-bench-test">3. Wiring the relay for a bench test</h2>

<p>Before touching the garage at all, test on the desk with the Arduino plugged into the computer.</p>

<table>
  <thead>
    <tr>
      <th>Relay pin</th>
      <th>Arduino pin</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">+</code></td>
      <td>5V</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-</code></td>
      <td>GND</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">S</code></td>
      <td>Digital pin 7</td>
    </tr>
  </tbody>
</table>

<p>Leave the COM/NO screw terminals <strong>empty</strong> during the bench test.</p>

<p><strong>What I learned:</strong> many of these cheap relay modules are <strong>active LOW</strong>: writing <code class="language-plaintext highlighter-rouge">LOW</code> to the signal pin turns the relay <em>on</em>. So the sketch starts by writing <code class="language-plaintext highlighter-rouge">HIGH</code> to keep it off.</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="kt">int</span> <span class="n">relayPin</span> <span class="o">=</span> <span class="mi">7</span><span class="p">;</span>

<span class="kt">void</span> <span class="nf">setup</span><span class="p">()</span> <span class="p">{</span>
  <span class="n">pinMode</span><span class="p">(</span><span class="n">relayPin</span><span class="p">,</span> <span class="n">OUTPUT</span><span class="p">);</span>
  <span class="n">digitalWrite</span><span class="p">(</span><span class="n">relayPin</span><span class="p">,</span> <span class="n">HIGH</span><span class="p">);</span>   <span class="c1">// relay off (active LOW module)</span>
  <span class="n">Serial</span><span class="p">.</span><span class="n">begin</span><span class="p">(</span><span class="mi">9600</span><span class="p">);</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="n">loop</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">Serial</span><span class="p">.</span><span class="n">available</span><span class="p">()</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">char</span> <span class="n">c</span> <span class="o">=</span> <span class="n">Serial</span><span class="p">.</span><span class="n">read</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">c</span> <span class="o">==</span> <span class="sc">'o'</span> <span class="o">||</span> <span class="n">c</span> <span class="o">==</span> <span class="sc">'O'</span><span class="p">)</span> <span class="p">{</span>
      <span class="n">digitalWrite</span><span class="p">(</span><span class="n">relayPin</span><span class="p">,</span> <span class="n">LOW</span><span class="p">);</span>  <span class="c1">// "press the button"</span>
      <span class="n">delay</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
      <span class="n">digitalWrite</span><span class="p">(</span><span class="n">relayPin</span><span class="p">,</span> <span class="n">HIGH</span><span class="p">);</span> <span class="c1">// "release"</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Open the Serial Monitor at 9600 baud, type <code class="language-plaintext highlighter-rouge">o</code>, press Enter → <strong>click, click</strong>. The little LED on the relay blinks with it.</p>

<p>Bonus check: put a multimeter in continuity mode across COM and NO. It should beep for exactly the half second the relay is closed.</p>

<h2 id="4-the-detour-johnny-five-and-firmata">4. The detour: Johnny-Five and Firmata</h2>

<p>My first instinct as a JavaScript developer was to drive the board from Node.js with <strong>Johnny-Five</strong>. It timed out.</p>

<p><strong>What I learned:</strong> Johnny-Five needs the <strong>StandardFirmata</strong> sketch uploaded to the board first (<code class="language-plaintext highlighter-rouge">File → Examples → Firmata → StandardFirmata</code>, the plain one, not the Plus version). Without it the computer and the board don’t speak the same protocol. In the end I didn’t need Johnny-Five at all, because a ten-line sketch listening on serial was simpler.</p>

<h2 id="5-programmer-is-not-responding">5. “programmer is not responding”</h2>

<p>Uploading anything to the Uno failed with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>avrdude: stk500_recv(): programmer is not responding
avrdude: stk500_getsync() attempt 1 of 10: not in sync: resp=0x00
</code></pre></div></div>

<p>This means the computer is shouting down the cable and the board isn’t answering. The checklist that helped:</p>

<ul>
  <li><strong>Pick the right port.</strong> On a Mac, ignore <code class="language-plaintext highlighter-rouge">Bluetooth-Incoming-Port</code>; look for <code class="language-plaintext highlighter-rouge">/dev/cu.usbmodem…</code> (or <code class="language-plaintext highlighter-rouge">/dev/cu.wchusbserial…</code> on clones).</li>
  <li><strong>Use a data cable</strong>, not a charge-only one. Lights turning on prove nothing.</li>
  <li><strong>Take the shield off</strong> and upload to the bare Uno first. A stacked shield can interfere with the serial pins.</li>
  <li><strong>Close anything else holding the port</strong>: the Serial Monitor, or a Node script still running in another terminal.</li>
  <li><strong>Clones may need the CH340 driver.</strong></li>
</ul>

<h2 id="6-soldering-is-optional-for-now">6. Soldering is optional (for now)</h2>

<p>The Arduino pins are small and I was nervous about bridging two of them with solder. Alternatives that are perfectly fine for this phase:</p>

<ul>
  <li><strong>Female-to-female / male-to-female jumper wires</strong> straight onto the header pins</li>
  <li>A <strong>solderless breadboard</strong> (great for testing, less great long-term in a cold garage)</li>
  <li>A <strong>screw terminal shield</strong>: strip the wire, tighten a screw, done</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>A garage door opener’s button input is a dry contact. A relay across COM/NO is all it takes.</li>
  <li>An Arduino Uno can’t do networking on its own; decide early what provides the network.</li>
  <li>Cheap relay modules are often active LOW.</li>
  <li>Bench test first: Serial Monitor + listening for the click + a multimeter on COM/NO.</li>
  <li>“programmer is not responding” is almost always port, cable, shield or a busy serial port.</li>
</ul>

<p><strong>Next up:</strong> getting a first-generation Raspberry Pi onto my network, which took far longer than the electronics.</p>]]></content><author><name>Tuomas Melin</name></author><category term="arduino" /><category term="relay" /><category term="electronics" /><category term="garage-door" /><category term="til" /><summary type="html"><![CDATA[I can open my garage door with a remote. I wanted to open it with an API call instead, and had no idea what electronics sit between "HTTP request" and "door moves".]]></summary></entry></feed>