<?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://nolan-alexander.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nolan-alexander.github.io/" rel="alternate" type="text/html" /><updated>2026-10-15T21:41:22+00:00</updated><id>https://nolan-alexander.github.io/feed.xml</id><title type="html">Nolan Alexander</title><subtitle>Portland. Bench notes on sensors and MQTT dumps.</subtitle><author><name>Nolan Alexander</name></author><entry><title type="html">MQTT wildcards on a file you already captured</title><link href="https://nolan-alexander.github.io/2026/10/14/parse-lines.html" rel="alternate" type="text/html" title="MQTT wildcards on a file you already captured" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://nolan-alexander.github.io/2026/10/14/parse-lines</id><content type="html" xml:base="https://nolan-alexander.github.io/2026/10/14/parse-lines.html"><![CDATA[<p>MQTT topic filters are not glob and not regex. I keep relearning that when I try to filter a capture with Unix tools.</p>

<p>On a live broker, <code class="language-plaintext highlighter-rouge">sensors/+</code> is one level (<code class="language-plaintext highlighter-rouge">sensors/lab</code> yes, <code class="language-plaintext highlighter-rouge">sensors/lab/temp</code> no). <code class="language-plaintext highlighter-rouge">sensors/#</code> is the remainder of the path, including zero extra levels depending on how you read the spec in the wild—people argue about <code class="language-plaintext highlighter-rouge">sensors/#</code> vs <code class="language-plaintext highlighter-rouge">sensors</code>. <code class="language-plaintext highlighter-rouge">#</code> as the whole filter is “everything,” except that a real broker will not deliver <code class="language-plaintext highlighter-rouge">$SYS/...</code> on a <code class="language-plaintext highlighter-rouge">#</code> subscription. A file on disk has no <code class="language-plaintext highlighter-rouge">$SYS</code> policy. If the line is in the dump, a filter is just a function of two strings.</p>

<p>I first filtered with <code class="language-plaintext highlighter-rouge">str.startswith</code>, which is the wrong model for topic levels. <code class="language-plaintext highlighter-rouge">topic.startswith("sensors")</code> matches <code class="language-plaintext highlighter-rouge">sensors_backup/foo</code>. <code class="language-plaintext highlighter-rouge">topic.startswith("sensors/")</code> misses a topic that is exactly <code class="language-plaintext highlighter-rouge">sensors</code>. Treating <code class="language-plaintext highlighter-rouge">#</code> as any substring matches payloads, comments, and the word <code class="language-plaintext highlighter-rouge">hash</code>.</p>

<p>What goes wrong with regex: <code class="language-plaintext highlighter-rouge">#</code> is not “rest of path.” <code class="language-plaintext highlighter-rouge">+</code> is not “any characters.” A regex you write in thirty seconds will accept <code class="language-plaintext highlighter-rouge">sensors/lab/temp</code> for <code class="language-plaintext highlighter-rouge">sensors/+</code> unless you anchor levels on <code class="language-plaintext highlighter-rouge">/</code>.</p>

<p>mqttlog implements the level walk in <code class="language-plaintext highlighter-rouge">topic_match</code> (<code class="language-plaintext highlighter-rouge">mqttlog/record.py</code>). Split both strings on <code class="language-plaintext highlighter-rouge">/</code>. <code class="language-plaintext highlighter-rouge">+</code> consumes one level. <code class="language-plaintext highlighter-rouge">#</code> is only legal as the last token and then succeeds. If the pattern is exhausted, the topic must be exhausted too—otherwise <code class="language-plaintext highlighter-rouge">sensors/+</code> would accept <code class="language-plaintext highlighter-rouge">sensors/lab/temp</code>. Tests in <code class="language-plaintext highlighter-rouge">tests/test_record.py</code> are the cases that bit me, not a tutorial:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sensors/lab/temp  vs  sensors/#     → True
actuators/fan     vs  sensors/#     → False
sensors/lab       vs  sensors/+     → True
sensors/lab/temp  vs  sensors/+     → False
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">mqttlog csv</code> and <code class="language-plaintext highlighter-rouge">replay</code> call that same <code class="language-plaintext highlighter-rouge">topic_match</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ PYTHONPATH=. python3 -m mqttlog csv lab.log --topic 'sensors/#' --out -
$ PYTHONPATH=. python3 -m mqttlog replay lab.log --topic 'sensors/+' --out out/
</code></pre></div></div>

<p>I also write comments in dumps (<code class="language-plaintext highlighter-rouge"># swapped DHT at 20:04</code>). A strict “three fields or die” parser raises on those. Skipping <code class="language-plaintext highlighter-rouge">#</code> and blank lines when slurping a whole file is not the interesting part of this tool. The interesting part is that the filter you would have used on mosquitto still means the same thing on a file from last weekend.</p>

<p>A 30-line <a href="https://gist.github.com/nolan-alexander/362209c525a3f4e943b50d78419c41df">filter_sensors.py</a> is what I used before the package existed. It implements “starts with <code class="language-plaintext highlighter-rouge">sensors/</code>” only. It does not implement <code class="language-plaintext highlighter-rouge">+</code>. If you care about the difference, use the library.</p>

<p>Code, MIT, CI: <a href="https://github.com/nolan-alexander/mqttlog">nolan-alexander/mqttlog</a>. I want that difference (file vs broker, <code class="language-plaintext highlighter-rouge">+</code> vs <code class="language-plaintext highlighter-rouge">#</code>) to be the thing a hiring reader remembers, not that I wrapped <code class="language-plaintext highlighter-rouge">str.split</code> in a module.</p>]]></content><author><name>Nolan Alexander</name></author><summary type="html"><![CDATA[MQTT topic filters are not glob and not regex. I keep relearning that when I try to filter a capture with Unix tools.]]></summary></entry><entry><title type="html">CI for a tool that talks to hardware it does not have</title><link href="https://nolan-alexander.github.io/2026/10/05/fake-sensors.html" rel="alternate" type="text/html" title="CI for a tool that talks to hardware it does not have" /><published>2026-10-05T00:00:00+00:00</published><updated>2026-10-05T00:00:00+00:00</updated><id>https://nolan-alexander.github.io/2026/10/05/fake-sensors</id><content type="html" xml:base="https://nolan-alexander.github.io/2026/10/05/fake-sensors.html"><![CDATA[<p>mqttlog’s job is files. The files come from a Pi, a DHT22, a reed switch, and a radio that browns out a USB hub when it transmits. None of that belongs in GitHub Actions.</p>

<p>If the tests require the breadboard, they will not run on a PR. If they do not run on a PR, the CI badge is decoration. I wanted the opposite: a skeptical skim of <a href="https://github.com/nolan-alexander/mqttlog">mqttlog</a> should see <code class="language-plaintext highlighter-rouge">unittest</code> actually exercise rotation, topic filters, and the CLI.</p>

<p>The cheap way out is to check in one golden <code class="language-plaintext highlighter-rouge">sample.log</code> and only test parse. That misses the rotator. Rotation depends on byte counts, not on “a file exists.” The tests write records in a temp dir with <code class="language-plaintext highlighter-rouge">--max-bytes 40</code> so a handful of lines must spill into <code class="language-plaintext highlighter-rouge">mqttlog-001.log</code>. If that test only ran when I had the DHT plugged in, it would not exist.</p>

<p><code class="language-plaintext highlighter-rouge">mqttlog sim</code> is a fixture generator, not a digital twin. It emits lab temp, RH, and a door contact on a 15s cadence in the same line format as a live dump. Door is <code class="language-plaintext highlighter-rouge">open</code> every fourth tick so the CSV is not a constant. Temp walks in 0.3° steps because a flat 20.0 looks like I forgot to write the loop. It is fake on purpose. CI should not pretend it smelled the room.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ PYTHONPATH=. python3 -m mqttlog sim --count 8 --out /tmp/sim --max-bytes 120
wrote 8 records to /tmp/sim/mqttlog-001.log
</code></pre></div></div>

<p>The hardware still taught me something tests cannot. The CP2102 and the radio shared a cheap hub. Meter on TX dropped to 4.4V when the radio keyed. Python was innocent. Baud 115200 dropped characters; 57600 held. I keep that in a private note so the next time a dump looks “corrupted” I check 5V before I check <code class="language-plaintext highlighter-rouge">select()</code>. That story does not go in CI. CI gets the simulator.</p>

<p><code class="language-plaintext highlighter-rouge">python -m unittest discover -s tests -v</code> covers parse, <code class="language-plaintext highlighter-rouge">+</code>/<code class="language-plaintext highlighter-rouge">#</code>, rotation, and CLI replay/csv. <code class="language-plaintext highlighter-rouge">.github/workflows/ci.yml</code> runs it on push. If you work on firmware-adjacent Python and you have been burned by “the test needs the board,” that is the design I am advertising: keep the I/O in files, fake the stream, never make Actions SSH to a Pi in my apartment.</p>]]></content><author><name>Nolan Alexander</name></author><summary type="html"><![CDATA[mqttlog’s job is files. The files come from a Pi, a DHT22, a reed switch, and a radio that browns out a USB hub when it transmits. None of that belongs in GitHub Actions.]]></summary></entry><entry><title type="html">Keeping MQTT history on disk without a live broker</title><link href="https://nolan-alexander.github.io/2026/09/27/mqtt-dumps-on-disk.html" rel="alternate" type="text/html" title="Keeping MQTT history on disk without a live broker" /><published>2026-09-27T00:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://nolan-alexander.github.io/2026/09/27/mqtt-dumps-on-disk</id><content type="html" xml:base="https://nolan-alexander.github.io/2026/09/27/mqtt-dumps-on-disk.html"><![CDATA[<p>Most MQTT tooling assumes you are looking at <em>now</em>. <code class="language-plaintext highlighter-rouge">mosquitto_sub</code>, MQTT Explorer, a Telegraf consumer: they subscribe, they print, they forget. That is the right default for a dashboard. It is the wrong default when you are staring at a breadboard at 21:00 and trying to answer “did <code class="language-plaintext highlighter-rouge">sensors/door/state</code> actually change while I was in the other room.”</p>

<p>On the bench Pi I run mosquitto on 1883 with no TLS (it is a LAN I can see). For a long time the “history” was a tmux pane:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mosquitto_sub -h 127.0.0.1 -t '#' -v &gt;&gt; ~/captures/lab.log
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-v</code> is topic, a space, then the payload. That looks like a log file until you treat it like one.</p>

<p><strong>Payloads have spaces.</strong> A door payload of <code class="language-plaintext highlighter-rouge">open with bounce</code> makes <code class="language-plaintext highlighter-rouge">awk '{print $3}'</code> a lie. <strong>I leave notes in the file.</strong> <code class="language-plaintext highlighter-rouge"># swapped DHT, yellow is data</code> is not a topic. <strong>Replaying through a broker to filter is theatre.</strong> If the bytes are already on disk, starting mosquitto so I can subscribe to <code class="language-plaintext highlighter-rouge">sensors/#</code> is a round trip I do not need.</p>

<p>I tried the usual Unix-shaped mistakes. <code class="language-plaintext highlighter-rouge">grep sensors lab.log</code> also matches <code class="language-plaintext highlighter-rouge">sensors</code> inside a payload. <code class="language-plaintext highlighter-rouge">awk '$2 ~ /^sensors\//'</code> drops my comments and still cannot see a payload as one field. I once passed <code class="language-plaintext highlighter-rouge">sensors/#</code> to awk as if <code class="language-plaintext highlighter-rouge">#</code> were a regex. It is not; in MQTT it is a wildcard that only means something at a topic-filter parser.</p>

<p>Existing tools did not fit this job. MQTT Explorer wants a live session. <code class="language-plaintext highlighter-rouge">mosquitto_sub</code> is live. sqlite-plus-broker is a project, not a Thursday night. I wanted a line format I could <code class="language-plaintext highlighter-rouge">tail</code>, <code class="language-plaintext highlighter-rouge">grep</code> (carefully), rotate, and CSV without standing anything up.</p>

<p>The line format I settled on is three fields, payload allowed to contain spaces:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;iso-timestamp&gt; &lt;topic&gt; &lt;payload...&gt;
</code></pre></div></div>

<p>Topic is one token. Payload is the rest of the line, spaces included. Lines starting with <code class="language-plaintext highlighter-rouge">#</code> are mine, not the bus. Rotation is by byte size because a 1 Hz stream fills a 4G card in a week if you never cut it (I did that once).</p>

<p><a href="https://github.com/nolan-alexander/mqttlog">mqttlog</a> is the CLI I use for that file. MIT, unittest, GitHub Actions. It is not a broker, not a time-series database, and not a replacement for MQTT Explorer. It is the missing “I already have the dump” path.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ PYTHONPATH=. python3 -m mqttlog replay lab.log --topic 'sensors/#' --out logs/
wrote 128 records to logs/mqttlog-000.log

$ PYTHONPATH=. python3 -m mqttlog csv lab.log --topic 'sensors/+' --out -
ts,topic,payload
2026-10-13T20:11:00-07:00,sensors/lab/temp,20.0
</code></pre></div></div>

<p>A real capture (comments included) is in <a href="https://gist.github.com/nolan-alexander/f4c0c24e2268719196141bcfadd2ea31">this gist</a>. If you do the same thing with <code class="language-plaintext highlighter-rouge">&gt;&gt; lab.log</code> and then wish you had <code class="language-plaintext highlighter-rouge">sensors/#</code> after the fact, that is the hole this fills. I am in Portland; if you hire people who write small Python around sensors and serial, that repo is the thing I would rather you clone than a LeetCode dump.</p>]]></content><author><name>Nolan Alexander</name></author><summary type="html"><![CDATA[Most MQTT tooling assumes you are looking at now. mosquitto_sub, MQTT Explorer, a Telegraf consumer: they subscribe, they print, they forget. That is the right default for a dashboard. It is the wrong default when you are staring at a breadboard at 21:00 and trying to answer “did sensors/door/state actually change while I was in the other room.”]]></summary></entry></feed>