<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Emir Mrkaljevic]]></title><description><![CDATA[Emir Mrkaljevic]]></description><link>https://emirmrkaljevic.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:54:55 GMT</lastBuildDate><atom:link href="https://emirmrkaljevic.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to spot shrinkflation at Lidl and Carrefour online]]></title><description><![CDATA[A 4-step routine for comparing grocery prices fairly, plus a free extension that remembers pack sizes for you]]></description><link>https://emirmrkaljevic.hashnode.dev/how-to-spot-shrinkflation-at-lidl-and-carrefour-online</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/how-to-spot-shrinkflation-at-lidl-and-carrefour-online</guid><category><![CDATA[shopping]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[browser extensions]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Tue, 03 Nov 2026 10:06:46 GMT</pubDate><content:encoded><![CDATA[<p>Shrinkflation rarely shows up on the price tag. The price stays at €2.49, but the pack goes from 500 g to 450 g. That is an 11.1% price rise per kilogram, and the only clue is a small number on the label.</p>
<p>Here is a simple routine for spotting it when you shop online at Lidl or Carrefour, plus a free tool that does the boring part for you.</p>
<p><img src="https://emirmrkaljevic.github.io/shrinkwatch-eu/img/demo.gif" alt="Shrinkflation warning on a fictional demo product: was 500 g for €2.49, now 450 g for €2.49" /></p>
<h2>1. Compare the price per kg or litre, not the pack price</h2>
<p>EU shops must show a unit price next to the selling price (<a href="https://eur-lex.europa.eu/eli/dir/1998/6/oj">Directive 98/6/EC</a>). On Lidl it usually appears under the price as "1 kg = 7.12 €". On Carrefour France it sits near the price as "€/kg" or "€/L".</p>
<p>The unit price is the only fair way to compare a 450 g pack with a 500 g one. If it is missing, divide the price by the pack size yourself: €2.49 / 0.45 kg = €5.53 per kg.</p>
<h2>2. Watch multipacks</h2>
<p>"6 x 1 l" or "4 x 125 g" packs are where mistakes happen. Check that the unit price uses the total size (6 l, 500 g), not a single item.</p>
<h2>3. Remember what the pack used to be</h2>
<p>This is the hard part. Neither shop tells you that the same product was bigger last month. You either remember it or you keep notes.</p>
<h2>4. Let your browser remember for you</h2>
<p>ShrinkWatch EU is a free, open-source extension for Chrome, Edge and Firefox. On a product page it:</p>
<ul>
<li>shows the unit price in a small badge, worked out from the price and pack size,</li>
<li>cross-checks the shop's own unit price and shows it when the two disagree,</li>
<li>saves the pack size locally and warns you if the same product comes back smaller.</li>
</ul>
<p>It was checked in a real desktop Chrome on Lidl in Germany, France, Spain, Italy, Poland and the Netherlands, and on Carrefour in France, Spain, Belgium and Poland. Lidl Germany works right after install; for the other sites you click "Enable on this shop" once in the popup. Some examples from those checks:</p>
<ul>
<li>lidl.fr, olive oil 1.5 l: €5.66 / l, matching the shop's own figure</li>
<li>lidl.it, butter 125 g: €7.12 / kg</li>
<li>carrefour.fr, 1 l of milk: €1.26 / l</li>
<li>carrefour.pl, 1 l of milk: 4.49 zł / l</li>
</ul>
<p>If a page prints the pack size only on the product photo, the badge shows the price and says the pack size is missing, so you know to check the label.</p>
<h2>Privacy</h2>
<p>No account, no analytics, no network requests. Prices and pack sizes stay in your browser.</p>
<h2>Try it</h2>
<ul>
<li>Firefox: <a href="https://addons.mozilla.org/en-US/firefox/addon/shrinkwatch-eu/">https://addons.mozilla.org/en-US/firefox/addon/shrinkwatch-eu/</a></li>
<li>Chrome and Edge install steps: <a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/#install">https://emirmrkaljevic.github.io/shrinkwatch-eu/#install</a></li>
<li>Lidl guide: <a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/lidl.html">https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/lidl.html</a></li>
<li>Carrefour guide: <a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/carrefour.html">https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/carrefour.html</a></li>
<li>Source and issues: <a href="https://github.com/emirmrkaljevic/shrinkwatch-eu">https://github.com/emirmrkaljevic/shrinkwatch-eu</a></li>
</ul>
<p>Found a product with a wrong figure? Open an issue with the link and it will be fixed.</p>
<p><em>This post was written with AI assistance and checked by me against the extension running on the shops.</em></p>
]]></content:encoded></item><item><title><![CDATA[The round at the base of a boss is a torus, so my STL to STEP tool now writes one]]></title><description><![CDATA[I build mesh2cad, an open-source CLI and MCP server that turns an STL (or OBJ/PLY) into a STEP file with real planes, cylinders, spheres, cones and fillets…]]></description><link>https://emirmrkaljevic.hashnode.dev/the-round-at-the-base-of-a-boss-is-a-torus-so-my-stl-to-step-tool-now-writes-one</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/the-round-at-the-base-of-a-boss-is-a-torus-so-my-stl-to-step-tool-now-writes-one</guid><category><![CDATA[3dprinting]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[cad]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 19:10:27 GMT</pubDate><content:encoded><![CDATA[<p>I build <a href="https://github.com/emirmrkaljevic/mesh2cad">mesh2cad</a>, an open-source CLI and MCP server that turns an STL (or OBJ/PLY) into a STEP file with real planes, cylinders, spheres, cones and fillets you can select and dimension in FreeCAD, Fusion or Onshape. Until last week one very common feature still came back as a pile of triangles: the fillet where a flat face meets a round one.</p>
<h2>Why that fillet is different</h2>
<p>A fillet between two flat walls is a piece of a cylinder, and mesh2cad has rebuilt those for a while. A fillet between a plane and a coaxial cylinder is not. Think of the round at the base of a boss, the softened rim of a hole, or the rounded floor of a blind hole. That surface is a quarter of a torus, tangent to both neighbours.</p>
<p>There are two cases:</p>
<ul>
<li><strong>Boss base and hole rim</strong> use the inner equator of the torus: major radius = cylinder radius + fillet radius.</li>
<li><strong>Blind hole floor</strong> uses the outer equator: major radius = cylinder radius minus fillet radius.</li>
</ul>
<p>In 0.18.0 mesh2cad fits that band, builds one torus face in the B-rep, sews it to the plane and the cylinder, and checks that the result is still one valid solid. The fidelity report now counts torus faces and lists each major and minor radius.</p>
<pre><code class="language-bash">pip install -U mesh2cad
mesh2cad convert boss_plate.stl -o boss_plate.step
</code></pre>
<h2>Numbers, including the bad ones</h2>
<p>All of these are synthetic parts, 3 random seeds each, from <code>python benchmarks/run.py</code> in the repo.</p>
<ul>
<li>Plate with an Ø16 boss and a 2 mm base fillet: exact on 3 of 3 seeds at 0 and at 0.05 mm noise. With no noise the minor radius error is 0.008 mm (major 10.012 mm, Hausdorff 0.026 mm). At 0.05 mm the worst minor error is 0.279 mm, Hausdorff 0.150 mm.</li>
<li>Ø12 hole with a 1.5 mm top round: exact with no noise (minor error 0.068 mm) and on 2 of 3 seeds at 0.05 mm. The third seed is a valid faceted solid.</li>
<li>Both rims rounded at 1.2 mm: exact with no noise (two tori, minor error 0.061 mm).</li>
<li>Ø10 blind hole with a 1.5 mm floor fillet: exact with no noise (minor 1.463 mm, major 3.529 mm, Hausdorff 0.021 mm).</li>
</ul>
<p>Where it does not work:</p>
<ul>
<li>At 0.2 mm of noise the boss fillet and the hole rim are not recovered. You get the sharp cylinder and its planes, still one valid solid, but the Hausdorff is the size of the missing round (0.864 mm on the boss).</li>
<li>At 0.05 mm the blind floor and the double-rounded hole fall back too.</li>
<li>A fillet that runs into a spherical corner (every edge of a box rounded) is still not rebuilt.</li>
<li>The CadQuery, OpenSCAD and FreeCAD script exports refuse a torus for now. The torus lives in the STEP only; <code>fillet_r</code> in the scripts is still just the four vertical corners of a rectangular base.</li>
</ul>
<p>I would rather have the tool fall back to a valid solid and say so in the report than invent a fillet that is not there, so that rule stays.</p>
<h2>Try it</h2>
<ul>
<li>Repo and README limits: <a href="https://github.com/emirmrkaljevic/mesh2cad">github.com/emirmrkaljevic/mesh2cad</a></li>
<li>Release notes: <a href="https://github.com/emirmrkaljevic/mesh2cad/releases/tag/v0.18.0">v0.18.0</a></li>
<li>PyPI: <a href="https://pypi.org/project/mesh2cad/">mesh2cad</a></li>
</ul>
<p>If you have an STL of a simple mechanical part where the rounds come back wrong, open an issue with the file. Those are the cases I want to fix next.</p>
]]></content:encoded></item><item><title><![CDATA[Building a tray app for a 480x320 USB PC screen]]></title><description><![CDATA[Temps, watts, a clock, weather and now playing on a 3.5 inch case panel]]></description><link>https://emirmrkaljevic.hashnode.dev/building-a-tray-app-for-a-480x320-usb-pc-screen</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/building-a-tray-app-for-a-480x320-usb-pc-screen</guid><category><![CDATA[Python]]></category><category><![CDATA[desktop]]></category><category><![CDATA[hardware]]></category><category><![CDATA[Windows]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 13:10:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae83d2012b2ff3dbf618660/d3837128-3849-43f1-a595-d1e3fc6acaef.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally posted on <a href="https://dev.to/emirmrkaljevic/building-a-tray-app-for-a-480x320-usb-pc-screen-5021">DEV</a>. Updated for version 1.3: clock, weather and now playing from any Windows player.</em></p>
<p>A lot of small USB panels show up next to a PC case: 3.5 inches, 480x320 (or 320x480 portrait), one vendor app that looks like 2009. I wanted temperatures, watts, and the song that's playing, set in type, not in a dashboard full of gauges.</p>
<p>So I built <strong>PanelOS</strong>: a Windows tray app that draws on that panel and stays out of the way.</p>
<p><img src="https://emirmrkaljevic.github.io/panelos-site/assets/theme_signature_landscape_3x.png" alt="Signature theme on a 3.5 inch USB panel" /></p>
<h2>The canvas is tiny</h2>
<p>480x320 is closer to a phone from 2012 than to a monitor. Every pixel matters:</p>
<ul>
<li>One dirty rectangle per second for live values (CPU/GPU load, temps, power). No full-frame redraws for a number that moved by one degree.</li>
<li>Album covers load as a full frame only when the track changes, then crossfade. Bright covers get a stronger scrim so white text stays readable.</li>
<li>Themes switch on the next frame from the tray, no restart, no splash.</li>
</ul>
<p>The default look is <strong>Signature</strong>: full-bleed album art fading into a dark left plate, huge thin numerals, one RAM | VRAM | FAN line, and the track with a progress bar.</p>
<h2>Now playing from Windows, not just one app</h2>
<p>Early versions read the track from the Spotify desktop app. Today it follows whatever Windows itself reports as the current media session, so browsers and other players work too, with Spotify as a fallback. No logins and no API keys.</p>
<h2>Clock and weather</h2>
<p>Newer versions add a local clock and weather for a city you pick in Settings (the city field suggests properly formatted names while you type). In Pro you can choose whether the background behind the clock shows the album cover or an animated scene that follows the real weather.</p>
<h2>Six themes, one tray</h2>
<p>Besides Signature there is Nature Flow (stats over a quiet wallpaper slideshow), Noir Editorial, Gallery, Chronograph, and Classic. Landscape or portrait, your accent color, start with Windows with a short USB delay.</p>
<p>Lite is free. Pro is a one-time $6.99 for all themes, two PCs, and every 1.x update. No subscription, no account.</p>
<h2>Sensors and USB without drama</h2>
<p>CPU package temp and fan RPM need admin once (the installer sets that up). Without it the panel still runs; those values just read zero. Confirmed panels are the common 3.5 inch devices with Hardware Id <code>VID_1A86&amp;PID_5722</code>, so check Device Manager before you buy a random listing (<a href="https://emirmrkaljevic.github.io/panelos-site/help/compatible-screens.html">compatible screens checklist</a>).</p>
<p>While working on panel protocols I also open-sourced <strong><a href="https://github.com/emirmrkaljevic/usb-rea">usb-rea</a></strong>: capture labelled clicks from a Windows vendor app, diff the packets, and get a report plus a starter pyusb driver.</p>
<h2>Try it</h2>
<ul>
<li>Free Lite: <a href="https://github.com/emirmrkaljevic/panelos-site/releases/latest/download/PanelOS-Setup.exe">download the installer</a> (Windows 10 and 11). Pro unlocks in the same app with a key.</li>
<li>Pro: <a href="https://emirmk.gumroad.com/l/vmhopx">PanelOS Pro on Gumroad</a>, $6.99 one-time</li>
<li>Site and help: <a href="https://emirmrkaljevic.github.io/panelos-site/">PanelOS</a> with a <a href="https://emirmrkaljevic.github.io/panelos-site/help/">help hub</a></li>
</ul>
<p>One honest note: the installer isn't code-signed yet, so Windows may show a SmartScreen warning the first time. The <a href="https://emirmrkaljevic.github.io/panelos-site/help/smartscreen.html">SmartScreen page</a> explains what you'll see.</p>
<p>If you own a matching 3.5 inch USB panel, try Lite first; if Classic shows on your screen, every Pro theme will too.</p>
]]></content:encoded></item><item><title><![CDATA[Which theme and apps does a Shopify store use? Reading it from the homepage HTML]]></title><description><![CDATA[If you sell to Shopify merchants (an app, an agency service, a theme), the first question about a lead is usually: which theme are they on, and which apps…]]></description><link>https://emirmrkaljevic.hashnode.dev/which-theme-and-apps-does-a-shopify-store-use-reading-it-from-the-homepage-html</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/which-theme-and-apps-does-a-shopify-store-use-reading-it-from-the-homepage-html</guid><category><![CDATA[shopify]]></category><category><![CDATA[Python]]></category><category><![CDATA[ecommerce]]></category><category><![CDATA[web scraping]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 12:04:11 GMT</pubDate><content:encoded><![CDATA[<p>If you sell to Shopify merchants (an app, an agency service, a theme), the first question about a lead is usually: which theme are they on, and which apps do they already pay for? You don't need a paid "store intelligence" tool for a first answer. Almost all of it is sitting in the public homepage HTML.</p>
<h2>1. The theme is in a JSON object</h2>
<p>Every Shopify storefront prints a <code>Shopify.theme</code> object in an inline script. One request is enough:</p>
<pre><code class="language-python">import json, re, requests

html = requests.get("https://www.deathwishcoffee.com/",
                    headers={"User-Agent": "Mozilla/5.0"}, timeout=20).text
m = re.search(r"Shopify\.theme\s*=\s*(\{.*?\});", html)
theme = json.loads(m.group(1)) if m else None
print(theme)
</code></pre>
<p>When I ran this today I got:</p>
<pre><code>deathwishcoffee.com: name "deathwish-shopify-20/production", schema_name "Dawn", schema_version "10.0.0", theme_store_id null
allbirds.com:        name "[DNAM Theme July 2026]", schema_name "allbirds-theme", schema_version "1.231.12", theme_store_id null
</code></pre>
<p><code>schema_name</code> is the useful field: it tells you the base theme (Dawn here, even though the store renamed its copy). <code>theme_store_id</code> is set when the theme was installed from the Theme Store and is null for custom or heavily forked themes, so it is also a decent hint that the store has a developer or agency.</p>
<h2>2. Apps leave fingerprints</h2>
<p>Apps inject scripts, app-embed blocks under <code>cdn.shopify.com/extensions/...</code>, or their own CDN domains. A small signature list goes a long way:</p>
<pre><code class="language-python">SIGNATURES = {
    "Klaviyo": ["klaviyo"],
    "Attentive": ["attn.tv", "attentive"],
    "Gorgias": ["gorgias"],
    "Rebuy": ["rebuyengine", "rebuy"],
    "Smile.io": ["smile.io"],
    "Yotpo": ["yotpo"],
    "Recharge": ["rechargecdn", "recharge"],
    "Judge.me": ["judge.me"],
    "Okendo": ["okendo"],
}
low = html.lower()
apps = [name for name, keys in SIGNATURES.items() if any(k in low for k in keys)]
print(apps)
</code></pre>
<p>On the two stores above: Death Wish showed Klaviyo, Rebuy, Gorgias, Smile.io and Recharge; Allbirds showed Attentive and Yotpo. Plain substring matching is crude (a blog post that mentions "klaviyo" would match), so in practice count only matches inside <code>&lt;script src&gt;</code>, <code>&lt;link href&gt;</code> and inline script blocks, and keep the signatures specific (domains beat brand words).</p>
<h2>3. Catalog numbers come from products.json</h2>
<p>Most stores also expose <code>/products.json?limit=250&amp;page=N</code>, which gives product count, price range, vendors and how many products are new. Combined with the theme and apps, that is a pretty complete lead row from two or three requests per store, with no login and no JavaScript rendering.</p>
<h2>Limits</h2>
<ul>
<li>Headless stores (Hydrogen or a custom frontend) don't print <code>Shopify.theme</code>, so treat "no theme object" as a headless signal, not an error.</li>
<li>Some apps load only after consent or on product pages, so homepage detection undercounts.</li>
<li>Be polite: one homepage request per store, a real User-Agent, and no hammering.</li>
</ul>
<h2>If you'd rather not maintain the signature list</h2>
<p>I built a <a href="https://apify.com/tinyrex/shopify-products-scraper">Shopify Products Scraper</a> on Apify that does exactly this in its "stores" mode: one row per store with theme (name, base theme, version, Theme Store ID), a headless flag, detected apps from about 75 signatures, plus catalog stats from products.json. It is pay-per-result (about $0.0035 per store) and stores that fail or aren't Shopify are not charged. Disclosure: it's mine. The code above is enough if you only need a handful of stores.</p>
]]></content:encoded></item><item><title><![CDATA[I diffed GNU coreutils against its Rust rewrite with auto-generated tests]]></title><description><![CDATA[Black-box behavioral tests for any CLI, so a rewrite can prove it behaves the same]]></description><link>https://emirmrkaljevic.hashnode.dev/i-diffed-gnu-coreutils-against-its-rust-rewrite-with-auto-generated-tests</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/i-diffed-gnu-coreutils-against-its-rust-rewrite-with-auto-generated-tests</guid><category><![CDATA[golang]]></category><category><![CDATA[Rust]]></category><category><![CDATA[Testing]]></category><category><![CDATA[cli]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 11:04:29 GMT</pubDate><content:encoded><![CDATA[<p><em>Update, November 2026: since this post, running behave against uutils has led to 23 upstream bug reports and 30 fix PRs, 13 of them merged. The current list is in the <a href="https://github.com/emirmrkaljevic/behave#what-it-found-on-day-one">behave README</a>. Originally published on <a href="https://dev.to/emirmrkaljevic/i-diffed-gnu-coreutils-against-its-rust-rewrite-with-auto-generated-tests-2ocl">dev.to</a>.</em></p>
<p>"Rewrite it in Rust" is easy to say. The hard part is proving the rewrite does what the old
tool did, including on input nobody thought about: an empty stdin, a file without a trailing
newline, <code>--output-delimiter -1</code>, a directory where a file should be.</p>
<p>So I built <strong><a href="https://github.com/emirmrkaljevic/behave">behave</a></strong>, a small Go tool that treats
a command-line program as a black box:</p>
<ol>
<li><strong>probe</strong>: read <code>--help</code> and the man page, find the flags, and generate cases: each flag
alone, flag pairs, value edge cases (<code>0</code>, <code>-1</code>, <code>abc</code>, empty, missing), stdin samples
(empty, unicode, CRLF, 5,000-character line, no trailing newline), the same samples as file
arguments, unknown flags and missing files.</li>
<li><strong>record</strong>: run each case three times in a fresh temp directory with a fixed environment.
If the runs disagree, the case is marked flaky (hello <code>sort --random-sort</code>).</li>
<li><strong>diff</strong>: replay every case against another binary and show what changed, per case and
per flag.</li>
</ol>
<pre><code class="language-sh">behave probe sort                       # ~290 cases into ./behave-sort/
behave diff behave-sort --impl ~/uu/sort --skip-tag meta --ignore-stderr
</code></pre>
<h2>What it found: GNU coreutils vs uutils 0.12.0</h2>
<table>
<thead>
<tr>
<th>tool</th>
<th>matching cases</th>
<th>differences</th>
</tr>
</thead>
<tbody><tr>
<td>wc</td>
<td>63/63</td>
<td>none</td>
</tr>
<tr>
<td>head</td>
<td>51/51</td>
<td>none</td>
</tr>
<tr>
<td>uniq</td>
<td>100/100</td>
<td>none</td>
</tr>
<tr>
<td>tr</td>
<td>63/63</td>
<td>none</td>
</tr>
<tr>
<td>cut</td>
<td>172/175</td>
<td><code>--output-delimiter -1</code> is rejected as if <code>-1</code> were a flag</td>
</tr>
<tr>
<td>base64</td>
<td>31/36</td>
<td><code>-d</code> on invalid input: GNU writes the bytes it decoded before failing, uutils writes nothing</td>
</tr>
<tr>
<td>sort</td>
<td>~260/290</td>
<td>usage errors exit with 1 instead of 2; <code>--debug</code> underline width; <code>--check --merge</code></td>
</tr>
</tbody></table>
<p>That's a strong result for uutils: four tools fully match on everything generated. The rest are
small and some are likely deliberate, but you would want to know about them before swapping
binaries in a script that checks <code>$? -eq 2</code>.</p>
<h2>jq vs gojq</h2>
<p>gojq is a well-known Go implementation of jq that documents its differences. behave finds them
quickly: 331 of 596 cases match (ignoring error wording). The per-flag table shows the reasons:
gojq drops <code>--seq</code>, <code>--ascii-output</code>, <code>--sort-keys</code> (it always sorts keys) and a few others, and
formats huge numbers differently (<code>1e1000</code> vs <code>1E+1000</code>).</p>
<h2>Making output comparable</h2>
<p>Raw byte comparison doesn't work across implementations, so behave normalizes before comparing:</p>
<ul>
<li>the program name (<code>jq: error</code> vs <code>gojq: error</code>)</li>
<li>temp directory paths, timestamps, ANSI colors, hex addresses</li>
<li>your own regex rules in <code>behave.yaml</code></li>
</ul>
<p>Specs are plain YAML, one file per case, so you can commit them next to your port and review
changes in PRs. <code>behave run</code> / <code>behave diff</code> exit non-zero on failures, so they work in CI.</p>
<h2>For porting with a coding assistant</h2>
<p><code>behave mcp</code> exposes <code>behave_exec</code>, <code>behave_probe</code>, <code>behave_add_case</code>, <code>behave_list_cases</code> and
<code>behave_diff</code> over MCP, so an assistant porting a tool can check its work after each change
instead of guessing.</p>
<h2>Limits</h2>
<p>The flag parser is heuristic (tested so far on GNU coreutils and jq help output),
interactive/TTY programs aren't supported yet, and tools that need a meaningful positional
argument (a jq filter, a sed script) need a few samples via <code>--arg</code>.</p>
<p>It's MIT and a single binary for Linux and macOS:
<strong><a href="https://github.com/emirmrkaljevic/behave">https://github.com/emirmrkaljevic/behave</a></strong>. If you're porting a CLI, I'd like to hear what it
gets wrong.</p>
<hr />
<p><em>More of my projects: <a href="https://github.com/emirmrkaljevic">github.com/emirmrkaljevic</a></em></p>
]]></content:encoded></item><item><title><![CDATA[I built a userscript and Firefox add-on that checks if EU discounts are real]]></title><description><![CDATA[Free, open source, local-only check against the EU 30-day lowest price rule]]></description><link>https://emirmrkaljevic.hashnode.dev/i-built-a-userscript-and-firefox-add-on-that-checks-if-eu-discounts-are-real</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/i-built-a-userscript-and-firefox-add-on-that-checks-if-eu-discounts-are-real</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Firefox]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:41:23 GMT</pubDate><content:encoded><![CDATA[<p>Every November the same thing happens: prices go up a little in October, then "drop" by 40% for Black Friday. In the EU there is actually a rule against this. I built truediscount to make that rule visible while you shop.</p>
<h2>The rule: Omnibus 30-day lowest price</h2>
<p>Since 2022 the EU Omnibus Directive (Article 6a of the Price Indication Directive) says that when a shop announces a price reduction, the "before" price must be the lowest price it charged in the previous 30 days. Not the RRP, not a price from last spring. Many shops now print this "lowest price in the last 30 days" somewhere on the page, often in small grey text far from the big red percentage.</p>
<p>So the honest discount is: (30-day lowest price minus current price) divided by the 30-day lowest price. Sometimes that number is much smaller than the badge. Sometimes it is zero or negative.</p>
<h2>What truediscount does</h2>
<ul>
<li>Finds the current price, the advertised "was" price and the 30-day lowest price on a product page</li>
<li>Recalculates the discount against the 30-day lowest price and shows it next to the shop's claim</li>
<li>Keeps a small local price history so you can see a graph of prices you have already seen, plus a watchlist with price alerts</li>
<li>Stores everything locally in the browser. No account, no server, no tracking</li>
</ul>
<h2>Three ways to install</h2>
<p><strong>Userscript (Greasy Fork).</strong> If you already use Tampermonkey, Violentmonkey or similar, this is the fastest option: <a href="https://greasyfork.org/en/scripts/602599-truediscount-is-this-discount-real">https://greasyfork.org/en/scripts/602599-truediscount-is-this-discount-real</a></p>
<p><strong>Firefox add-on.</strong> Version 0.13.0 is now live on addons.mozilla.org: <a href="https://addons.mozilla.org/en-US/firefox/addon/truediscount/">https://addons.mozilla.org/en-US/firefox/addon/truediscount/</a> . The Firefox zip is also on the GitHub releases if you prefer a manual install.</p>
<p><strong>Chrome-based browsers.</strong> Load the Chrome zip from the releases page as an unpacked extension.</p>
<h2>A few things I learned</h2>
<ul>
<li><strong>Price parsing is the hard part.</strong> "1.299,00 €", "€1,299.00" and "1 299,00 zł" all need to end up as the same number. I normalise by locale and treat the last separator followed by exactly two digits as the decimal mark.</li>
<li><strong>Shops phrase the 30-day price differently in every language.</strong> "Niedrigster Preis der letzten 30 Tage", "prix le plus bas des 30 derniers jours", "najniższa cena z 30 dni" and so on. The extension keeps a list of phrases per language and searches near the price block.</li>
<li><strong>One codebase, several targets.</strong> The MV3 extension and the userscript share the detection code. A small build script produces the Firefox zip, the Chrome zip and the .user.js from the same source.</li>
<li><strong>Local-only is a feature.</strong> Not having a backend made the privacy policy short and the review process simple.</li>
</ul>
<h2>Try it, break it</h2>
<p>The code is MIT licensed: <a href="https://github.com/emirmrkaljevic/truediscount">https://github.com/emirmrkaljevic/truediscount</a></p>
<p>If you find a shop where detection fails, an issue with the product URL is the most helpful thing you can send. Happy (honest) Black Friday shopping.</p>
]]></content:encoded></item><item><title><![CDATA[What a real user's USB capture found that my synthetic tests didn't: two bugs in usb-rea]]></title><description><![CDATA[For weeks every test of usb-rea ran on captures I generated myself, on protocols documented by liquidctl and rivalcfg, and on a few public Wireshark…]]></description><link>https://emirmrkaljevic.hashnode.dev/what-a-real-user-s-usb-capture-found-that-my-synthetic-tests-didn-t-two-bugs-in-usb-rea</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/what-a-real-user-s-usb-capture-found-that-my-synthetic-tests-didn-t-two-bugs-in-usb-rea</guid><category><![CDATA[Linux]]></category><category><![CDATA[Python]]></category><category><![CDATA[reverse engineering]]></category><category><![CDATA[usb]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:07:28 GMT</pubDate><content:encoded><![CDATA[<p>For weeks every test of <a href="https://github.com/emirmrkaljevic/usb-rea">usb-rea</a> ran on captures I generated myself, on protocols documented by liquidctl and rivalcfg, and on a few public Wireshark samples. All of that passed. Then a real user shared a real capture, and within a day it had found two bugs that none of my tests could have found.</p>
<p>This is the fifth post about usb-rea, an open-source tool I built that diffs USB captures per labelled click in a vendor app and writes a pyusb driver. The <a href="https://dev.to/emirmrkaljevic/turning-windows-only-usb-gadgets-into-open-python-drivers-with-usb-rea-1an4">first one</a> covered commands, the <a href="https://dev.to/emirmrkaljevic/naming-what-a-usb-device-reports-back-rpm-flags-and-modes-from-the-vendor-apps-screen-nl7">second one</a> naming what the device reports back, the <a href="https://dev.to/emirmrkaljevic/the-packets-a-vendor-app-sends-before-you-click-anything-finding-usb-handshakes-and-session-tokens-1gpd">third one</a> handshakes and session tokens, the <a href="https://dev.to/emirmrkaljevic/telling-a-video-stream-to-a-case-lcd-apart-from-the-commands-jpeg-and-raw-rgb565-in-usb-captures-4kc">fourth one</a> video streams to case LCDs. This one is about what happened when the tool met hardware I don't own.</p>
<h2>The capture</h2>
<p>On <a href="https://github.com/liquidctl/liquidctl/issues/936">liquidctl issue 936</a> the owner of an ASUS TUF Gaming LC III AIO (the one with an LCD on the pump) shared a USBPcap capture from Windows while changing the screen brightness in the vendor app. The full capture is big: about 76,000 packets, nearly all of them a 320x320 JPEG video the app streams to the screen at around 70 frames per second, plus four tiny brightness writes hidden in between.</p>
<p>That capture is what pushed me to add image stream detection in v0.7.4 (the fourth post). After that, the full capture gave the right answer: brightness is byte 4 of a 440-byte message on interrupt EP 0x01, the video goes to EP 0x03 with a <code>08 nn 00 80</code> header, and the device acks each frame with <code>08 81</code> on EP 0x84. That matched what the owner had worked out by hand.</p>
<p>Then I asked if I could keep a trimmed copy as a regression test. They said yes, and turning it into a test is where the two bugs showed up.</p>
<h2>Making a fixture you can publish</h2>
<p>A raw capture is not something to commit to a public repo. It has other devices on the same bus, and the video frames are literally whatever was on the screen. So the stored copy:</p>
<ul>
<li>keeps only this device's packets: the setup, the first 8 frames and two frames on each side of each brightness change (589 packets, 6.5 KB gzipped)</li>
<li>zeroes the image data in every frame, keeping only the JPEG headers and the end marker, so nothing that was on the screen is in the file</li>
<li>shifts the timestamps to a fixed date and replaces the IRP ids with counters</li>
</ul>
<p>The test in <code>tests/test_user_capture_lc3.py</code> then checks the VID:PID, the brightness command and the stream end to end. The first run failed twice.</p>
<h2>Bug 1: USBPcap puts the control reply in the "complete" record</h2>
<p>usb-rea finds the vendor and product id from the GET_DESCRIPTOR reply at the start of a capture. On this file it said there wasn't one. Here is what v0.7.5 printed at the top of the report:</p>
<pre><code>Device: `unknown (no GET_DESCRIPTOR in capture)`
</code></pre>
<p>But the reply is right there. This is the real output of <code>usb-rea transfers</code> on the fixture (first two lines):</p>
<pre><code>     1 dev   2 ep 0x80 control   IN  8006000100001200 |
     2 dev   2 ep 0x80 control   IN  12 01 10 01 00 00 00 40 05 0b 7b 1c 51 00 01 02 03 01
</code></pre>
<p>USBPcap logs a control transfer in stages. Frame 1 is the setup stage (<code>80 06 00 01 ...</code>, GET_DESCRIPTOR for the device descriptor). The descriptor itself (<code>12 01 ...</code>, with <code>05 0b 7b 1c</code> being 0B05:1C7B little-endian) arrives in a separate record for the "complete" stage, stage 3. My parser only took control IN data from the stage it expected, so on captures where the data comes in stage 3 the descriptor was simply dropped. My own synthetic USBPcap files never did that, which is exactly why the tests never caught it.</p>
<p>v0.7.6 reads control IN data from the complete record too. Same file, new report:</p>
<pre><code>Device: `0b05:1c7b`
</code></pre>
<h2>Bug 2: counting the ack over the whole capture</h2>
<p>The second failure was quieter. v0.7.5 found the video stream and the brightness command, but no frame ack. Worse, the <code>08 81</code> acks didn't just disappear: they ended up in the report as a "reply to <code>brightness</code>", because a couple of them happened to arrive a few milliseconds after a brightness write.</p>
<p>The old rule was: a fixed device-to-host message is the ack if it shows up about once per frame over the whole capture (between 0.5x and 1.2x the number of frames). On the full capture that held. On the trimmed one it didn't: the fixture keeps the acks for frames I cut out, so it has 66 acks for 24 frames. That's an artefact of trimming, but the same thing happens on a real capture whenever the app keeps polling while it pauses the video, and a global ratio can't tell the difference.</p>
<p>v0.7.6 counts the ack per frame instead: a fixed device-to-host message between one frame's start and the next (and no more than 1 s after the frame's last packet). It's the ack when at least half of the frames have one and most of those have exactly one. The new <code>frames_acked</code> field says how many frames it was seen for. The report now has this line in the stream section (real output on the fixture):</p>
<pre><code>- Ack: `08 81 00 00 00 00 00 00 00 00 00 00 00 00 00 00` on EP 0x84, 66x (frames 30, 52, 94, 116, 138)
</code></pre>
<p>And because the acks are set aside with the stream, the bogus "Reply to <code>brightness</code>" section is gone. Summarised from the <code>usb-rea infer</code> JSON of both versions on the fixture, same labels:</p>
<pre><code>0.7.5  vid/pid None None   ack None                     responses 1
0.7.6  vid/pid 2821 7291   ack EP 0x84, frames_acked 18 responses 0
</code></pre>
<p>(2821 and 7291 are 0x0B05 and 0x1C7B.)</p>
<h2>What the trimmed copy can't tell you</h2>
<p>Some numbers in the fixture report are about the trimming, not the device, and I'd rather say so:</p>
<ul>
<li>The frame rate shows as about 0.2 frames per second, because most frames were cut out. The full capture is around 70.</li>
<li>The brightness command comes out at 60% confidence from two clean samples (<code>brightness 0</code> and <code>brightness 50</code>); the third action is ignored because it sent a differently shaped transaction. On the full capture it's 100%.</li>
<li>The image content is zeros, so the fixture can't test anything that depends on the pixels.</li>
</ul>
<p>What it does test is everything the bugs were about: the device descriptor in a USBPcap complete record, the stream headers, the per-frame ack, and the brightness byte on its own endpoint.</p>
<h2>Try it</h2>
<pre><code class="language-bash">pip install -U usb-rea          # 0.7.6
usb-rea demo --device lcd
</code></pre>
<p>To run it on the same fixture from a clone of the repo:</p>
<pre><code class="language-bash">gunzip -c tests/data/asus-tuf-lc3-lcd.pcap.gz &gt; lc3.pcap
usb-rea report lc3.pcap -l "brightness 0;brightness 50;brightness 100" -o lc3.md
</code></pre>
<p>The lesson for me: synthetic captures test the inference, real captures test the assumptions about how capture tools write files. If you have a USB device whose app only runs on Windows, one USBPcap capture while you change one setting is the most useful thing you can send. Device requests are open: <a href="https://github.com/emirmrkaljevic/usb-rea">https://github.com/emirmrkaljevic/usb-rea</a></p>
<p>Thanks to the owner of the LC III who shared the capture and agreed to the trimmed copy being kept as a test.</p>
<p><em>I built usb-rea with help from AI coding tools; the inference itself is deterministic and every claim in its reports points at captured frames.</em></p>
]]></content:encoded></item><item><title><![CDATA[What my cheap Wi-Fi pet feeder does on my network]]></title><description><![CDATA[I watched a camera feeder's traffic for an evening and turned the process into a small open tool]]></description><link>https://emirmrkaljevic.hashnode.dev/what-my-cheap-wi-fi-pet-feeder-does-on-my-network</link><guid isPermaLink="true">https://emirmrkaljevic.hashnode.dev/what-my-cheap-wi-fi-pet-feeder-does-on-my-network</guid><category><![CDATA[iot]]></category><category><![CDATA[Security]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Python]]></category><category><![CDATA[networking]]></category><dc:creator><![CDATA[Emir Mrkaljevic]]></dc:creator><pubDate>Mon, 02 Nov 2026 07:37:35 GMT</pubDate><content:encoded><![CDATA[<p>I bought a cheap Wi-Fi pet feeder with a camera and a microphone. Instead of just trusting it, I spent an evening watching what it actually does on my own network. Then I turned that capture and grading flow into a small open source tool, <a href="https://github.com/emirmrkaljevic/iotaudit">iotaudit</a> (MIT, macOS and Linux, no account, everything stays local).</p>
<h2>How to try it</h2>
<pre><code class="language-bash">pipx install iotaudit
sudo iotaudit auto
</code></pre>
<p><code>iotaudit auto</code> asks for consent, finds the devices on your own network, runs the safe checks, captures passively and writes a one-page HTML report with an A to F grade per device. You can also feed it a pcap from Wireshark, your router or PCAPdroid on Android.</p>
<h2>What the feeder actually did</h2>
<ul>
<li><strong>Telnet (port 23) was open</strong> on the LAN, and the app has no setting to turn it off.</li>
<li><strong>It hardcodes public DNS</strong>, so it skips the router and any Pi-hole you run.</li>
<li><strong>It talks to US cloud servers</strong> over TLS and MQTT, plus a STUN/TURN handshake, which is the usual pattern for peer to peer video.</li>
<li><strong>No idle uploads.</strong> With the app closed it only sent a tiny heartbeat about once a minute.</li>
</ul>
<h2>The honest part: I couldn't capture the video</h2>
<p>When I opened the live stream, almost no video showed up in my capture. Live video from these devices often goes straight from the camera to the phone over Wi-Fi, so it never passes through the laptop doing the capture, or it rides IPv6 link-local addresses that an IPv4 capture never sees.</p>
<p>So the tool now captures IPv6 and NDP too, and when a peer to peer session is set up but no stream is observed, the report says so in plain English instead of pretending:</p>
<blockquote>
<p>Live video set up but not observed here: it most likely went straight to your phone over Wi-Fi. Not observed does not mean it did not happen.</p>
</blockquote>
<p>I think that matters. A privacy tool that overclaims is worse than no tool.</p>
<h2>What the report covers</h2>
<ul>
<li>Who each device talks to: domains, IPs, countries and organisations</li>
<li>Encryption: plaintext HTTP, old TLS, or custom high entropy protocols</li>
<li>Open ports with short risk notes (telnet, RTSP, UPnP, ADB)</li>
<li>UPnP port forwards your router opened, read only</li>
<li>Idle upload check and a traffic timeline</li>
<li><code>iotaudit diff</code> to see what changed since the last audit</li>
<li>HTML, PDF, CSV and a Home Assistant summary</li>
</ul>
<p>Findings are indicators from observed traffic, not proof of anything about a vendor. Only audit networks and devices you own.</p>
<p>Code and a sample report: <a href="https://github.com/emirmrkaljevic/iotaudit">github.com/emirmrkaljevic/iotaudit</a>. There is also a small library of device pages (what each type of device is expected to contact) at <a href="https://emirmrkaljevic.github.io/iotaudit/">emirmrkaljevic.github.io/iotaudit</a>. Feedback and new device profiles are very welcome.</p>
]]></content:encoded></item></channel></rss>