<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Emir Mrkaljević</title>
    <description>The latest articles on DEV Community by Emir Mrkaljević (@emirmrkaljevic).</description>
    <link>https://dev.to/emirmrkaljevic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4173741%2Fc4a83360-e88a-4eb8-a467-c3df03f8af7d.png</url>
      <title>DEV Community: Emir Mrkaljević</title>
      <link>https://dev.to/emirmrkaljevic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emirmrkaljevic"/>
    <language>en</language>
    <item>
      <title>"Make the hole 2 mm bigger" is now one command for an STL you converted to CAD</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Sun, 01 Nov 2026 19:10:41 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/make-the-hole-2-mm-bigger-is-now-one-command-for-an-stl-you-converted-to-cad-4k0h</link>
      <guid>https://dev.to/emirmrkaljevic/make-the-hole-2-mm-bigger-is-now-one-command-for-an-stl-you-converted-to-cad-4k0h</guid>
      <description>&lt;p&gt;When I started &lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;mesh2cad&lt;/a&gt;, the sentence I kept in my head was "make the hole 2 mm bigger". You have an STL of a bracket, the hole is a little too tight for your bolt, and the original CAD file is gone. Editing triangles is miserable, so mesh2cad fits the mesh and writes a CadQuery, OpenSCAD or FreeCAD script with named numbers at the top.&lt;/p&gt;

&lt;p&gt;That got you a file where &lt;code&gt;hole_1_d = 10.0&lt;/code&gt; was sitting right there. You still had to open it, find the line, change it, run the script and hope the result was sane. 0.17.0 closes that loop with two commands: &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;edit&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"mesh2cad[cadquery]"&lt;/span&gt;
mesh2cad convert bracket.stl &lt;span class="nt"&gt;--cadquery&lt;/span&gt; bracket.py
mesh2cad params bracket.py
mesh2cad edit bracket.py &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;hole_1_d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;+2 &lt;span class="nt"&gt;--output-script&lt;/span&gt; bracket_12.py &lt;span class="nt"&gt;-o&lt;/span&gt; bracket_12.step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What you can change
&lt;/h2&gt;

&lt;p&gt;The demo part is a 50 x 30 x 6 mm plate with rounded corners, a 10 mm hole in the middle and two 5.5 mm holes for M5 bolts. After &lt;code&gt;convert&lt;/code&gt;, &lt;code&gt;params&lt;/code&gt; prints this (real output):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name      value  kind      unit  note
base_x    50.0   length    mm    base length (X)
base_y    30.0   length    mm    base width (Y)
base_t    6.0    length    mm    base thickness (Z)
base_cx   0.0    position  mm
base_cy   0.0    position  mm
base_z0   -3.0   position  mm    bottom face height
fillet_r  3.0    radius    mm    vertical corner radius
hole_1_d  10.0   diameter  mm    diameter
hole_1_x  0.0    position  mm
hole_1_y  0.0    position  mm
hole_2_d  5.5    diameter  mm    diameter
hole_2_x  -15.0  position  mm
hole_2_y  0.0    position  mm
hole_3_d  5.5    diameter  mm    diameter
hole_3_x  15.0   position  mm
hole_3_y  0.0    position  mm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works the same on &lt;code&gt;.py&lt;/code&gt;, &lt;code&gt;.scad&lt;/code&gt; and &lt;code&gt;.FCMacro&lt;/code&gt; files, because all three backends use the same names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the hole 2 mm bigger
&lt;/h2&gt;

&lt;p&gt;A value with a leading &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt; is a delta, a plain number is an absolute value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ mesh2cad edit bracket.py --set hole_1_d=+2 --output-script bracket_12.py -o bracket_12.step
hole_1_d: 10.0 -&amp;gt; 12.0 (diameter, mm)
volume: 8197.31 -&amp;gt; 7989.96 mm^3 (delta -207.35)
bbox min: [-25.000, -15.000, -3.000] -&amp;gt; [-25.000, -15.000, -3.000]
bbox max: [25.000, 15.000, 3.000] -&amp;gt; [25.000, 15.000, 3.000]
valid solid: yes
wrote script bracket_12.py
wrote bracket_12.step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The volume delta is a quick sanity check you can do by hand: a ring between 10 mm and 12 mm through 6 mm of plate is pi x (36 - 25) x 6 = 207.35 mm^3. The bounding box did not move, which is what you expect when only a hole changes.&lt;/p&gt;

&lt;p&gt;The script diff is exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;&amp;lt; hole_1_d = 10.0  # diameter
&lt;/span&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt; hole_1_d = 12.0  # diameter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;edit&lt;/code&gt; replaces the number and leaves every other byte alone, so the file stays readable and you can keep editing it by hand. You can pass several &lt;code&gt;--set&lt;/code&gt; flags at once, for example opening both bolt holes for M8 clearance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ mesh2cad edit bracket.py --set hole_2_d=+4 --set hole_3_d=+4 -o b2.step
hole_2_d: 5.5 -&amp;gt; 9.5 (diameter, mm)
hole_3_d: 5.5 -&amp;gt; 9.5 (diameter, mm)
volume: 8197.31 -&amp;gt; 7631.82 mm^3 (delta -565.49)
...
valid solid: yes
script unchanged on disk
wrote b2.step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--in-place&lt;/code&gt; or &lt;code&gt;--output-script&lt;/code&gt;, the script on disk is not touched and you only get the new solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edits that cannot work are refused
&lt;/h2&gt;

&lt;p&gt;I cared more about this part than about the happy path. A 40 mm hole in a 30 mm wide plate is not a part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;mesh2cad edit bracket.py &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;hole_1_d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;40 &lt;span class="nt"&gt;-o&lt;/span&gt; bad.step
&lt;span class="nv"&gt;hole_1_d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;40.0 does not fit: the hole is larger than the wall it sits &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;30.0 mm on the shorter side&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is written. The same goes for a counterbore that no longer clears its through-hole, a fillet that eats the short side of the base, a draft that closes a boss, an unknown parameter name (the error lists the names that exist), or a render that is empty or not one valid solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  For agents: list_params and edit_params
&lt;/h2&gt;

&lt;p&gt;The MCP server got the same two tools. &lt;code&gt;list_params(script_path)&lt;/code&gt; returns the table above as data, and &lt;code&gt;edit_params(script_path, changes, output_path?)&lt;/code&gt; takes something like &lt;code&gt;{"hole_1_d": "+2"}&lt;/code&gt; and returns old and new values, validity, the volume and bounding-box delta, and warnings. So in Claude Desktop or Cursor you can literally say "make the hole 2 mm bigger" about an STL and the agent has a safe, checked way to do it instead of rewriting the script itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mesh2cad"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mesh2cad"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limits, plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It only edits scripts mesh2cad generated. A hand-written CadQuery file is refused.&lt;/li&gt;
&lt;li&gt;The fit-checks are the obvious ones (does the hole fit in that face), not a constraint solver.&lt;/li&gt;
&lt;li&gt;Outline and cutout point lists are listed but not edited as one number. Moving a corner of an outline is still a text edit.&lt;/li&gt;
&lt;li&gt;Simple parts give short, readable parameter lists. Complex ones do not: the Prusa MK3S Y belt holder converts to a valid script, but with 282 parameters, most of them small cut positions and lengths. You can edit them, but nobody would call that list friendly yet.&lt;/li&gt;
&lt;li&gt;OpenSCAD and FreeCAD need the &lt;code&gt;openscad&lt;/code&gt; and &lt;code&gt;freecadcmd&lt;/code&gt; programs on &lt;code&gt;PATH&lt;/code&gt; to render. Without them the script is still rewritten and the command says the solid was not rendered.&lt;/li&gt;
&lt;li&gt;It is not a PartDesign body or a constrained sketch. The number changes, and the same script runs again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it without installing anything, the &lt;a href="https://huggingface.co/spaces/mesh2cad/mesh2cad" rel="noopener noreferrer"&gt;gallery on Hugging Face&lt;/a&gt; has precomputed Prusa parts with STEP, CadQuery and OpenSCAD downloads. Issues with STLs that do not convert are the most useful thing you can send me: &lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;github.com/emirmrkaljevic/mesh2cad&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>3dprinting</category>
      <category>cad</category>
      <category>opensource</category>
    </item>
    <item>
      <title>What a real user's USB capture found that my synthetic tests didn't: two bugs in usb-rea</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:06:46 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/what-a-real-users-usb-capture-found-that-my-synthetic-tests-didnt-two-bugs-in-usb-rea-57k</link>
      <guid>https://dev.to/emirmrkaljevic/what-a-real-users-usb-capture-found-that-my-synthetic-tests-didnt-two-bugs-in-usb-rea-57k</guid>
      <description>&lt;p&gt;For weeks every test of &lt;a href="https://github.com/emirmrkaljevic/usb-rea" rel="noopener noreferrer"&gt;usb-rea&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://dev.to/emirmrkaljevic/turning-windows-only-usb-gadgets-into-open-python-drivers-with-usb-rea-1an4"&gt;first one&lt;/a&gt; covered commands, the &lt;a href="https://dev.to/emirmrkaljevic/naming-what-a-usb-device-reports-back-rpm-flags-and-modes-from-the-vendor-apps-screen-nl7"&gt;second one&lt;/a&gt; naming what the device reports back, the &lt;a href="https://dev.to/emirmrkaljevic/the-packets-a-vendor-app-sends-before-you-click-anything-finding-usb-handshakes-and-session-tokens-1gpd"&gt;third one&lt;/a&gt; handshakes and session tokens, the &lt;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"&gt;fourth one&lt;/a&gt; video streams to case LCDs. This one is about what happened when the tool met hardware I don't own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capture
&lt;/h2&gt;

&lt;p&gt;On &lt;a href="https://github.com/liquidctl/liquidctl/issues/936" rel="noopener noreferrer"&gt;liquidctl issue 936&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;08 nn 00 80&lt;/code&gt; header, and the device acks each frame with &lt;code&gt;08 81&lt;/code&gt; on EP 0x84. That matched what the owner had worked out by hand.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a fixture you can publish
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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)&lt;/li&gt;
&lt;li&gt;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&lt;/li&gt;
&lt;li&gt;shifts the timestamps to a fixed date and replaces the IRP ids with counters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test in &lt;code&gt;tests/test_user_capture_lc3.py&lt;/code&gt; then checks the VID:PID, the brightness command and the stream end to end. The first run failed twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: USBPcap puts the control reply in the "complete" record
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Device: `unknown (no GET_DESCRIPTOR in capture)`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the reply is right there. This is the real output of &lt;code&gt;usb-rea transfers&lt;/code&gt; on the fixture (first two lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;USBPcap logs a control transfer in stages. Frame 1 is the setup stage (&lt;code&gt;80 06 00 01 ...&lt;/code&gt;, GET_DESCRIPTOR for the device descriptor). The descriptor itself (&lt;code&gt;12 01 ...&lt;/code&gt;, with &lt;code&gt;05 0b 7b 1c&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;v0.7.6 reads control IN data from the complete record too. Same file, new report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Device: `0b05:1c7b`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 2: counting the ack over the whole capture
&lt;/h2&gt;

&lt;p&gt;The second failure was quieter. v0.7.5 found the video stream and the brightness command, but no frame ack. Worse, the &lt;code&gt;08 81&lt;/code&gt; acks didn't just disappear: they ended up in the report as a "reply to &lt;code&gt;brightness&lt;/code&gt;", because a couple of them happened to arrive a few milliseconds after a brightness write.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;frames_acked&lt;/code&gt; field says how many frames it was seen for. The report now has this line in the stream section (real output on the fixture):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- 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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because the acks are set aside with the stream, the bogus "Reply to &lt;code&gt;brightness&lt;/code&gt;" section is gone. Summarised from the &lt;code&gt;usb-rea infer&lt;/code&gt; JSON of both versions on the fixture, same labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(2821 and 7291 are 0x0B05 and 0x1C7B.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What the trimmed copy can't tell you
&lt;/h2&gt;

&lt;p&gt;Some numbers in the fixture report are about the trimming, not the device, and I'd rather say so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The frame rate shows as about 0.2 frames per second, because most frames were cut out. The full capture is around 70.&lt;/li&gt;
&lt;li&gt;The brightness command comes out at 60% confidence from two clean samples (&lt;code&gt;brightness 0&lt;/code&gt; and &lt;code&gt;brightness 50&lt;/code&gt;); the third action is ignored because it sent a differently shaped transaction. On the full capture it's 100%.&lt;/li&gt;
&lt;li&gt;The image content is zeros, so the fixture can't test anything that depends on the pixels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; usb-rea          &lt;span class="c"&gt;# 0.7.6&lt;/span&gt;
usb-rea demo &lt;span class="nt"&gt;--device&lt;/span&gt; lcd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run it on the same fixture from a clone of the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; tests/data/asus-tuf-lc3-lcd.pcap.gz &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; lc3.pcap
usb-rea report lc3.pcap &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"brightness 0;brightness 50;brightness 100"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; lc3.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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: &lt;a href="https://github.com/emirmrkaljevic/usb-rea" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/usb-rea&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks to the owner of the LC III who shared the capture and agreed to the trimmed copy being kept as a test.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>python</category>
      <category>reverseengineering</category>
      <category>usb</category>
    </item>
    <item>
      <title>From STL to an editable FreeCAD macro: change one number, run it again</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:10:47 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/from-stl-to-an-editable-freecad-macro-change-one-number-run-it-again-4bm5</link>
      <guid>https://dev.to/emirmrkaljevic/from-stl-to-an-editable-freecad-macro-change-one-number-run-it-again-4bm5</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;mesh2cad&lt;/a&gt; turns an STL into CAD. Every part gets a STEP file with real planes, cylinders and cones. When the part is simple enough it also gets a script with named parameters, so "make the hole 2 mm bigger" is one edited number instead of an afternoon of mesh surgery.&lt;/p&gt;

&lt;p&gt;Until now that script was CadQuery or OpenSCAD. A lot of people who print parts use FreeCAD, though, and asking them to install CadQuery just to change a hole felt wrong. So 0.16.0 adds a third output: a FreeCAD macro.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; mesh2cad
mesh2cad convert plate.stl &lt;span class="nt"&gt;--freecad&lt;/span&gt; plate.FCMacro
freecadcmd plate.FCMacro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the macro looks like
&lt;/h2&gt;

&lt;p&gt;For a 40 x 30 x 6 plate with a 10 mm hole, the part that matters is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# --- parameters ---
&lt;/span&gt;&lt;span class="n"&gt;base_x&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;  &lt;span class="c1"&gt;# base length (X)
&lt;/span&gt;&lt;span class="n"&gt;base_y&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;30.0&lt;/span&gt;  &lt;span class="c1"&gt;# base width (Y)
&lt;/span&gt;&lt;span class="n"&gt;base_t&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;  &lt;span class="c1"&gt;# base thickness (Z)
&lt;/span&gt;&lt;span class="n"&gt;base_cx&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="n"&gt;base_cy&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="n"&gt;base_z0&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;  &lt;span class="c1"&gt;# bottom face height
&lt;/span&gt;&lt;span class="n"&gt;hole_1_d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;  &lt;span class="c1"&gt;# diameter
&lt;/span&gt;&lt;span class="n"&gt;hole_1_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="n"&gt;hole_1_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="c1"&gt;# --- model ---
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_cx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;base_x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_cy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;base_y&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_z0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_z0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;base_t&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeCylinder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hole_1_d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hole_1_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hole_1_y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_z0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;hole_1_d = 12.0&lt;/code&gt;, run the macro again, and you have a &lt;code&gt;Part::Feature&lt;/code&gt; with the bigger hole plus a fresh &lt;code&gt;plate.step&lt;/code&gt; next to the macro. The cylinders and cones are analytic in FreeCAD, not &lt;code&gt;$fn&lt;/code&gt; polygons like in OpenSCAD.&lt;/p&gt;

&lt;p&gt;The parameter names are the same as in the CadQuery and OpenSCAD scripts (&lt;code&gt;base_t&lt;/code&gt;, &lt;code&gt;hole_1_d&lt;/code&gt;, &lt;code&gt;hole_1_cbore_d&lt;/code&gt;, &lt;code&gt;fillet_r&lt;/code&gt;, &lt;code&gt;extrude_len&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;cut_1&lt;/code&gt;, &lt;code&gt;clip_1_d&lt;/code&gt;), so if you compare outputs side by side they line up. Under the hood it is FreeCAD's Part API: &lt;code&gt;makeBox&lt;/code&gt;, &lt;code&gt;makeCylinder&lt;/code&gt;, &lt;code&gt;makeCone&lt;/code&gt;, a polygon extruded along X, Y or Z, &lt;code&gt;fuse&lt;/code&gt; / &lt;code&gt;cut&lt;/code&gt; / &lt;code&gt;common&lt;/code&gt;, and &lt;code&gt;makeFillet&lt;/code&gt; on the four vertical corners of a rectangular base.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is checked before it is written
&lt;/h2&gt;

&lt;p&gt;This is the rule I care about most in this project. A script that looks plausible but builds the wrong part is worse than no script.&lt;/p&gt;

&lt;p&gt;So mesh2cad runs the macro with &lt;code&gt;freecadcmd&lt;/code&gt;, meshes the resulting solid finely, and compares it with the input STL. The gate is the same as for the other two exporters: maximum deviation at most max(4 x tol, 0.1 mm), and volume error at most 1%. If the part is outside the supported shapes, or the solid misses, nothing is written and the command exits with code 2 and says why.&lt;/p&gt;

&lt;p&gt;FreeCAD is not a Python dependency. If &lt;code&gt;freecadcmd&lt;/code&gt; isn't on your &lt;code&gt;PATH&lt;/code&gt;, the macro is still written, but the command tells you it is unverified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six real printer parts
&lt;/h2&gt;

&lt;p&gt;I test on six printed parts from the Original Prusa i3 MK3S (Prusa Research's files, GPL-2.0). All six now export a FreeCAD macro that passes the check:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;part&lt;/th&gt;
&lt;th&gt;FreeCAD macro deviation&lt;/th&gt;
&lt;th&gt;limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plug-aligner&lt;/td&gt;
&lt;td&gt;0.0000 mm&lt;/td&gt;
&lt;td&gt;0.1 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;endstop-block&lt;/td&gt;
&lt;td&gt;0.0421 mm&lt;/td&gt;
&lt;td&gt;0.1 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-belt-idler&lt;/td&gt;
&lt;td&gt;0.1693 mm&lt;/td&gt;
&lt;td&gt;0.192 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-belt-holder&lt;/td&gt;
&lt;td&gt;0.0896 mm&lt;/td&gt;
&lt;td&gt;0.154 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-motor-holder&lt;/td&gt;
&lt;td&gt;0.1843 mm&lt;/td&gt;
&lt;td&gt;0.266 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-rod-holder&lt;/td&gt;
&lt;td&gt;0.1300 mm&lt;/td&gt;
&lt;td&gt;0.138 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each also stays under 1% volume error. Four of them match the CadQuery deviation exactly. The idler is a little farther from the mesh than the CadQuery script, and the belt holder is slightly closer than both other exporters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The macro is a plain Part script, not a PartDesign body with constrained sketches. Editing means changing a number at the top and running it again. You don't get a feature tree you can click through.&lt;/li&gt;
&lt;li&gt;It covers the same shapes as the other exporters: boxes, extruded outlines, holes and counterbores, 45 degree chamfers, drafted bosses, planar clips, fillets on four vertical corners. A boss on an extruded outline, a sphere, or a region that wasn't fitted is refused, and the message says which.&lt;/li&gt;
&lt;li&gt;Six boxy parts from one printer is a small test set. Organic shapes, fillets everywhere and scanned meshes still get STEP at best.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  For AI agents too
&lt;/h2&gt;

&lt;p&gt;mesh2cad is also an MCP server (&lt;code&gt;pip install "mesh2cad[mcp]"&lt;/code&gt;, then &lt;code&gt;mesh2cad mcp&lt;/code&gt;). There is a new &lt;code&gt;to_freecad(input_path, output_path?, tol?)&lt;/code&gt; tool next to &lt;code&gt;to_cadquery&lt;/code&gt; and &lt;code&gt;to_openscad&lt;/code&gt;. It returns the parameters and, when FreeCAD ran it, the measured deviation, or &lt;code&gt;verified: false&lt;/code&gt; when &lt;code&gt;freecadcmd&lt;/code&gt; is missing.&lt;/p&gt;

&lt;p&gt;If you want to see the before and after without installing anything, there is a &lt;a href="https://huggingface.co/spaces/mesh2cad/mesh2cad" rel="noopener noreferrer"&gt;small gallery on Hugging Face&lt;/a&gt; with precomputed parts.&lt;/p&gt;

&lt;p&gt;If you try it on a part of your own and it gets refused, I'd like to see it. Open an &lt;a href="https://github.com/emirmrkaljevic/mesh2cad/issues" rel="noopener noreferrer"&gt;issue&lt;/a&gt; with the STL (or a link) and I'll look at what is missing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>3dprinting</category>
      <category>freecad</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to spot shrinkflation at Lidl and Carrefour online</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:04:16 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/how-to-spot-shrinkflation-at-lidl-and-carrefour-online-3cj7</link>
      <guid>https://dev.to/emirmrkaljevic/how-to-spot-shrinkflation-at-lidl-and-carrefour-online-3cj7</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckort51dml8pt23ntuvd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckort51dml8pt23ntuvd.gif" alt="Shrinkflation warning on a fictional demo product: was 500 g for €2.49, now 450 g for €2.49" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Compare the price per kg or litre, not the pack price
&lt;/h2&gt;

&lt;p&gt;EU shops must show a unit price next to the selling price (&lt;a href="https://eur-lex.europa.eu/eli/dir/1998/6/oj" rel="noopener noreferrer"&gt;Directive 98/6/EC&lt;/a&gt;). 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".&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Watch multipacks
&lt;/h2&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Remember what the pack used to be
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Let your browser remember for you
&lt;/h2&gt;

&lt;p&gt;ShrinkWatch EU is a free, open-source extension for Chrome, Edge and Firefox. On a product page it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shows the unit price in a small badge, worked out from the price and pack size,&lt;/li&gt;
&lt;li&gt;cross-checks the shop's own unit price and shows it when the two disagree,&lt;/li&gt;
&lt;li&gt;saves the pack size locally and warns you if the same product comes back smaller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;lidl.fr, olive oil 1.5 l: €5.66 / l, matching the shop's own figure&lt;/li&gt;
&lt;li&gt;lidl.it, butter 125 g: €7.12 / kg&lt;/li&gt;
&lt;li&gt;carrefour.fr, 1 l of milk: €1.26 / l&lt;/li&gt;
&lt;li&gt;carrefour.pl, 1 l of milk: 4.49 zł / l&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy
&lt;/h2&gt;

&lt;p&gt;No account, no analytics, no network requests. Prices and pack sizes stay in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install steps: &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/#install" rel="noopener noreferrer"&gt;https://emirmrkaljevic.github.io/shrinkwatch-eu/#install&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lidl guide: &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/lidl.html" rel="noopener noreferrer"&gt;https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/lidl.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Carrefour guide: &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/carrefour.html" rel="noopener noreferrer"&gt;https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/carrefour.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source and issues: &lt;a href="https://github.com/emirmrkaljevic/shrinkwatch-eu" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/shrinkwatch-eu&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Found a product with a wrong figure? Open an issue with the link and it will be fixed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and checked by me against the extension running on the shops.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopping</category>
      <category>privacy</category>
      <category>opensource</category>
      <category>extensions</category>
    </item>
    <item>
      <title>Telling a video stream to a case LCD apart from the commands: JPEG and raw RGB565 in USB captures</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Fri, 30 Oct 2026 18:06:48 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/telling-a-video-stream-to-a-case-lcd-apart-from-the-commands-jpeg-and-raw-rgb565-in-usb-captures-4kc</link>
      <guid>https://dev.to/emirmrkaljevic/telling-a-video-stream-to-a-case-lcd-apart-from-the-commands-jpeg-and-raw-rgb565-in-usb-captures-4kc</guid>
      <description>&lt;p&gt;Capture the USB traffic of an AIO cooler with a screen on the pump cap, change the brightness four times in the vendor app, and diff the clicks. What you expect is one small command with one byte that follows the slider. What you get is tens of thousands of 1 KB packets, because the app never stops sending the picture to the screen while you click.&lt;/p&gt;

&lt;p&gt;This is the fourth post about &lt;a href="https://github.com/emirmrkaljevic/usb-rea" rel="noopener noreferrer"&gt;usb-rea&lt;/a&gt;, an open-source tool I built that diffs USB captures per labelled click and writes a pyusb driver. The &lt;a href="https://dev.to/emirmrkaljevic/turning-windows-only-usb-gadgets-into-open-python-drivers-with-usb-rea-1an4"&gt;first one&lt;/a&gt; covered commands, the &lt;a href="https://dev.to/emirmrkaljevic/naming-what-a-usb-device-reports-back-rpm-flags-and-modes-from-the-vendor-apps-screen-nl7"&gt;second one&lt;/a&gt; naming what the device reports back, the &lt;a href="https://dev.to/emirmrkaljevic/the-packets-a-vendor-app-sends-before-you-click-anything-finding-usb-handshakes-and-session-tokens-1gpd"&gt;third one&lt;/a&gt; handshakes and session tokens. This one is about screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weakness a real capture showed
&lt;/h2&gt;

&lt;p&gt;A user on &lt;a href="https://github.com/liquidctl/liquidctl/issues/936" rel="noopener noreferrer"&gt;liquidctl issue 936&lt;/a&gt; shared a public USBPcap capture of an ASUS TUF LC III LCD while changing the brightness. It was the first real capture of that kind I had run usb-rea on, and the old version got it wrong in an instructive way: the video packets landed inside the brightness actions, and the "brightness" usb-rea reported was made of image bytes.&lt;/p&gt;

&lt;p&gt;The diff wasn't broken. It did exactly what it was told: find the bytes that change with the label. In a capture full of video, video bytes change with everything.&lt;/p&gt;

&lt;p&gt;So since v0.7.4 usb-rea sets image streams aside &lt;strong&gt;before&lt;/strong&gt; anything else runs. On that capture the stream turned out to be a 320x320 JPEG video on its own endpoint, and once it was out of the way the brightness command was easy: byte 4 on EP 0x01 is the percentage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoded images: look for the magic bytes
&lt;/h2&gt;

&lt;p&gt;JPEG, PNG and GIF are the easy case because every image starts with a known marker (&lt;code&gt;FF D8 FF&lt;/code&gt; for JPEG). The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On one host-to-device channel, at least 3 packets start an image at the &lt;strong&gt;same offset&lt;/strong&gt;. That offset is the per-packet header length.&lt;/li&gt;
&lt;li&gt;A frame runs from its start packet to the image's end marker (JPEG &lt;code&gt;FF D9&lt;/code&gt;, counting embedded thumbnails so an EXIF preview doesn't end the frame early), the next start, or a pause.&lt;/li&gt;
&lt;li&gt;Each header byte is constant, &lt;code&gt;nn&lt;/code&gt; (number of packets in the frame) or &lt;code&gt;ii&lt;/code&gt; (index of the packet in the frame).&lt;/li&gt;
&lt;li&gt;A fixed device-to-host message sent about once per frame is the ack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the synthetic LCD from the demo, which streams a 240x240 JPEG video next to its brightness command. This is the real output of &lt;code&gt;usb-rea demo --device lcd&lt;/code&gt;, with the 64-byte brightness line trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;captured 5 labelled actions, device 1234:abcd

brightness   100%  0x12 | 0x01 | 0x00 | 0x80 | round(value * 1 + 0) | 0x00 | 0x00 | ...

image stream, kept out of the commands: jpeg stream on interrupt EP 0x03: 240x240, 159 frames in 950 packets of 512 bytes, header `08 nn 00 80` then `08 ii 00 00`; ack 08 81 00 00 00 00 00 00 00 00 00 00 00 00 00 00 on EP 0x84 (driver: send_image())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the matching part of &lt;code&gt;report.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Frames: 159 (3 to 8 packets each, 950 packets in all, 2 of them from a frame cut off by the capture)
- Image size: 240x240, 1507 to 3985 bytes
- Rate: about 20 frames per second
- Chunk: 512 bytes per packet, the last one padded
- Header (4 bytes): first packet `08 nn 00 80`, then `08 ii 00 00`
- Ack: `08 81 00 00 ...` on EP 0x84, 160x (frames 8, 18, 28, 42, 54)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image size comes from the JPEG SOF marker, so you also learn the panel resolution for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Raw pixels: no magic, so look at the size
&lt;/h2&gt;

&lt;p&gt;Plenty of cheap case screens don't take JPEG at all. They take plain pixels: RGB565 (2 bytes per pixel) or RGB888 (3 bytes), usually behind a short "draw" command. Nothing in those bytes says "image". This is now on main (not released yet), and the trick is arithmetic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Group packets on one channel into &lt;strong&gt;runs&lt;/strong&gt;: large packets with no pause between them, all but the last the same length.&lt;/li&gt;
&lt;li&gt;A run is a frame when its payload is exactly &lt;code&gt;width x height x bytes per pixel&lt;/code&gt; for a common screen size (80x160 up to 1920x480, both orientations).&lt;/li&gt;
&lt;li&gt;Or, better, when the short packet sent just before the run names a region and the payload is exactly that region. usb-rea tries x, y, width, height and x0, y0, x1, y1 as 16-bit fields in either byte order, plus four 10-bit fields packed into 5 bytes.&lt;/li&gt;
&lt;li&gt;Three or more frames make a stream. The draw commands go with the pixels; other short commands on the same endpoint stay commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The region version matters because these screens rarely redraw everything. A clock or a temperature readout is a small rectangle sent every second, and without the region those frames would look like random large commands.&lt;/p&gt;

&lt;p&gt;Output of &lt;code&gt;usb-rea demo --device lcd --raw rgb565&lt;/code&gt;, where the synthetic screen is 160x128 and every 4th frame only redraws a 48x16 clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;image stream, kept out of the commands: raw rgb565 little-endian stream on bulk EP 0x02: 160x128, 79 frames (20 redraw part of the screen) in 4937 packets of 512 bytes, each frame after a command `55 aa 02 00 xx xx yy yy ww ww hh hh`; ack 55 aa 82 00 on EP 0x82 (driver: send_image())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and from the report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Screen: 160x128 RGB565, 40960 bytes (2 per pixel) per full frame
- Pixel byte order: little-endian (neighbouring pixels are smooth that way)
- Region: the command gives x, y, width, height as 16-bit little-endian fields from byte 4; 20 of 79 frames redraw only part of the screen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The byte order line is my favourite small heuristic in this release. RGB565 can be sent little or big endian, and the capture doesn't say which. But real pictures are smooth: neighbouring pixels have similar colours. So usb-rea decodes a frame both ways and keeps the order where neighbours differ less. On a flat colour it says it can't tell instead of guessing.&lt;/p&gt;

&lt;p&gt;As a ground truth I wrote a test fixture from the public description of the Turing Smart Screen rev A protocol: 6-byte commands with x, y, end x and end y packed as 10-bit fields, &lt;code&gt;DISPLAY_BITMAP&lt;/code&gt; = 197, then RGB565 little-endian pixels. usb-rea finds the 320x480 stream, the draw command &lt;code&gt;rr rr rr rr rr c5&lt;/code&gt; (&lt;code&gt;rr&lt;/code&gt; = packed region), the partial redraws, and leaves the hello, orientation and brightness commands on the same endpoint alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  16-bit header fields
&lt;/h2&gt;

&lt;p&gt;Raw frames are big. A 128x160 RGB888 frame in 64-byte packets is 1024 packets, which doesn't fit a one-byte counter, so devices use 16-bit ones. The header description now reads a count or index above 255 as a u16 and shows it doubled, with its byte order: &lt;code&gt;00 00 nn nn&lt;/code&gt;, then &lt;code&gt;ii ii nn nn&lt;/code&gt;. The driver fills both bytes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The driver side
&lt;/h2&gt;

&lt;p&gt;The generated driver gets &lt;code&gt;send_image()&lt;/code&gt;. For a JPEG stream you pass JPEG bytes of the captured size. For a raw stream you pass a Pillow image (converted to RGB565 in the captured byte order) or bytes already in the stream's format, and if the app used region commands you can redraw just part of the screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;lcd_driver&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Device&lt;/span&gt;

&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Device&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wallpaper.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clock_pixels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# just the clock
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pillow is optional; the driver doesn't import it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can't do (yet)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A screen size that isn't in the list and has no region command is missed.&lt;/li&gt;
&lt;li&gt;The same byte count can fit several shapes: 320x480 RGB565 is also 480x320, and 320x320 RGB888. The report lists the alternatives so you can check on the device.&lt;/li&gt;
&lt;li&gt;Region fields are only looked for in the separate draw command, and width is expected before height.&lt;/li&gt;
&lt;li&gt;RGB888 channel order (RGB or BGR) is not worked out, and streams split over several endpoints are not detected.&lt;/li&gt;
&lt;li&gt;A frame with a pause longer than 30 ms inside it gets split.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; usb-rea          &lt;span class="c"&gt;# 0.7.5&lt;/span&gt;
usb-rea demo &lt;span class="nt"&gt;--device&lt;/span&gt; lcd
usb-rea demo &lt;span class="nt"&gt;--device&lt;/span&gt; lcd &lt;span class="nt"&gt;--raw&lt;/span&gt; rgb565
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's also an MCP server (&lt;code&gt;usb-rea mcp&lt;/code&gt;), and the &lt;code&gt;streams&lt;/code&gt; field in its output carries all of the above. Sending anything to real hardware still needs explicit confirmation from a human.&lt;/p&gt;

&lt;p&gt;If you own a case or AIO screen whose app only runs on Windows, a capture while you change one setting would help a lot, especially raw-pixel ones. Issues are open: &lt;a href="https://github.com/emirmrkaljevic/usb-rea" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/usb-rea&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>python</category>
      <category>reverseengineering</category>
      <category>usb</category>
    </item>
    <item>
      <title>Six of six: the last printer part that my STL to CAD tool refused now gets an editable script</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Thu, 29 Oct 2026 19:04:12 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/six-of-six-the-last-printer-part-that-my-stl-to-cad-tool-refused-now-gets-an-editable-script-2cl1</link>
      <guid>https://dev.to/emirmrkaljevic/six-of-six-the-last-printer-part-that-my-stl-to-cad-tool-refused-now-gets-an-editable-script-2cl1</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;mesh2cad&lt;/a&gt; turns an STL into CAD. Every part gets a STEP file with real planes, cylinders and cones. When the part is simple enough it also gets a CadQuery or OpenSCAD script with named parameters, so you can change &lt;code&gt;hole_3_d&lt;/code&gt; instead of editing triangles. Before any script is written, it is rebuilt and compared to the mesh, and it is refused if it misses.&lt;/p&gt;

&lt;p&gt;I test on six real Original Prusa i3 MK3S parts. In &lt;a href="https://dev.to/emirmrkaljevic/a-slanted-wall-and-a-slot-into-a-hole-getting-one-more-printer-part-to-an-editable-script-38gg"&gt;the last post&lt;/a&gt; five of them had a script and one was refused. With 0.15.0 all six do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why y-belt-holder was refused
&lt;/h2&gt;

&lt;p&gt;The script exporter builds a solid from outlines of the part seen along X, Y and Z, and intersects them. That works when each feature runs through the whole part. y-belt-holder breaks that in several ways at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the base is full height for part of the length and then steps down to a thin guide&lt;/li&gt;
&lt;li&gt;a hexagonal nut pocket and a bore exist for only part of the length&lt;/li&gt;
&lt;li&gt;five slits are only 0.2 mm thick&lt;/li&gt;
&lt;li&gt;the belt teeth, including a few small B-spline patches, move along X&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A silhouette taken along the full length fills every opening that exists for only part of it. The result was 2.517 mm off with a 1.97% volume error, against a limit of 0.154 mm and 1%. So it got exact STEP and no script.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;When the three earlier approaches (silhouette intersection, partial-depth union, wall-snapped rebuild) have all missed, 0.15.0 tries one more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;start from one bounding extrusion along the axis where the part changes&lt;/li&gt;
&lt;li&gt;split the length into spans where the outer cross-section stays within 0.05 mm of itself&lt;/li&gt;
&lt;li&gt;on each span, cut the extrusion back to the real section (&lt;code&gt;cut_N&lt;/code&gt;, with the kept section as &lt;code&gt;cut_N_cutout_J&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a gap between two parallel faces from 0.12 mm to 0.55 mm becomes a polygonal cut, which covers the thin slits&lt;/li&gt;
&lt;li&gt;an inner loop that keeps its place along the axis, like the hex pocket or the bore, becomes a polygonal cut over its span&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A part that already passes with a simpler solid never reaches this step, so the other five results did not move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The gate is the same as before: max deviation at most max(4 x tolerance, 0.1 mm), volume error at most 1%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;part&lt;/th&gt;
&lt;th&gt;exact STEP&lt;/th&gt;
&lt;th&gt;script (CadQuery / OpenSCAD)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plug-aligner&lt;/td&gt;
&lt;td&gt;0.0000 mm&lt;/td&gt;
&lt;td&gt;0.0000 / 0.0000 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;endstop-block&lt;/td&gt;
&lt;td&gt;0.0168 mm&lt;/td&gt;
&lt;td&gt;0.0421 / 0.0421 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-belt-idler&lt;/td&gt;
&lt;td&gt;0.0082 mm&lt;/td&gt;
&lt;td&gt;0.1534 / 0.1639 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-motor-holder&lt;/td&gt;
&lt;td&gt;0.1001 mm&lt;/td&gt;
&lt;td&gt;0.1843 / 0.1843 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-rod-holder&lt;/td&gt;
&lt;td&gt;0.2178 mm&lt;/td&gt;
&lt;td&gt;0.1300 / 0.1300 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;y-belt-holder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.1602 mm&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.0943 / 0.0965 mm&lt;/strong&gt; (was refused at 2.517 mm)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The y-belt-holder scripts have a 0.70% volume error, under the 1% limit but higher than the other parts. The new tests are a small synthetic bar that steps down along X, with a hexagonal tunnel and a 0.3 mm slit, checked in both exporters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "editable" means on this part
&lt;/h2&gt;

&lt;p&gt;I want to be honest here. On the simpler parts most of the geometry is named: hole diameters, depths, chamfers, counterbores. On y-belt-holder the overall size, the bore and the spans are clean numbers, but the belt teeth and the slits come out as polygon points inside a cut. You can move them, and the script is far easier to change than a mesh, but you won't get a &lt;code&gt;tooth_pitch&lt;/code&gt; parameter. Recognising repeated features like teeth is a separate problem I haven't solved.&lt;/p&gt;

&lt;p&gt;Six parts is also a small test set, all from one printer and all fairly boxy. Organic shapes, fillets everywhere and scanned meshes still get STEP at best, and often not even a clean one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; mesh2cad
mesh2cad convert part.stl &lt;span class="nt"&gt;-o&lt;/span&gt; part.step
mesh2cad convert part.stl &lt;span class="nt"&gt;--openscad&lt;/span&gt; part.scad
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also runs as an MCP server (&lt;code&gt;mesh2cad mcp&lt;/code&gt;), so an assistant can convert a part and change a parameter for you. If you have an STL of a simple mechanical part that fails, an issue with the file attached helps most: &lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;github.com/emirmrkaljevic/mesh2cad&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>3dprinting</category>
      <category>cad</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a userscript and Firefox add-on that checks if EU discounts are real</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:09:14 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/i-built-a-userscript-and-firefox-add-on-that-checks-if-eu-discounts-are-real-20nj</link>
      <guid>https://dev.to/emirmrkaljevic/i-built-a-userscript-and-firefox-add-on-that-checks-if-eu-discounts-are-real-20nj</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: Omnibus 30-day lowest price
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What truediscount does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Finds the current price, the advertised "was" price and the 30-day lowest price on a product page&lt;/li&gt;
&lt;li&gt;Recalculates the discount against the 30-day lowest price and shows it next to the shop's claim&lt;/li&gt;
&lt;li&gt;Keeps a small local price history so you can see a graph of prices you have already seen&lt;/li&gt;
&lt;li&gt;Stores everything locally in the browser. No account, no server, no tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three ways to install
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Userscript (Greasy Fork).&lt;/strong&gt; If you already use Tampermonkey, Violentmonkey or similar, this is the fastest option: &lt;a href="https://greasyfork.org/en/scripts/602599-truediscount-is-this-discount-real" rel="noopener noreferrer"&gt;https://greasyfork.org/en/scripts/602599-truediscount-is-this-discount-real&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firefox add-on.&lt;/strong&gt; The add-on is currently in review on addons.mozilla.org. Until it is approved you can grab the Firefox zip from the GitHub releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome-based browsers.&lt;/strong&gt; Load the Chrome zip from the releases page as an unpacked extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price parsing is the hard part.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shops phrase the 30-day price differently in every language.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One codebase, several targets.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-only is a feature.&lt;/strong&gt; Not having a backend made the privacy policy short and the review process simple.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it, break it
&lt;/h2&gt;

&lt;p&gt;The code is MIT licensed: &lt;a href="https://github.com/emirmrkaljevic/truediscount" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/truediscount&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI tools helped with editing this post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>firefox</category>
    </item>
    <item>
      <title>How to validate a list of LEI codes in bulk with the free GLEIF API (Python)</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:03:31 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/how-to-validate-a-list-of-lei-codes-in-bulk-with-the-free-gleif-api-python-3o2b</link>
      <guid>https://dev.to/emirmrkaljevic/how-to-validate-a-list-of-lei-codes-in-bulk-with-the-free-gleif-api-python-3o2b</guid>
      <description>&lt;p&gt;If you work with counterparties, suppliers or KYC data, you've probably got a spreadsheet column full of LEIs (Legal Entity Identifiers, the 20-character IDs every regulated company needs to trade). Some of them are typos, some belong to companies that closed, and some lapsed because nobody paid the yearly renewal. GLEIF, the foundation that runs the global LEI system, publishes all of this through a free JSON API with no key, so checking a whole list takes one short script.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Catch typos before calling any API
&lt;/h2&gt;

&lt;p&gt;An LEI is 18 letters or digits followed by 2 check digits (ISO 17442). The check uses the same MOD 97-10 trick as IBANs: turn each letter into a number (A=10 up to Z=35), read the result as one big integer, and it must leave a remainder of 1 when divided by 97.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checksum_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;int(c, 36)&lt;/code&gt; does the letter conversion for free, because base 36 maps 0-9 to themselves and A-Z to 10-35. This alone catches most copy-paste mistakes, like a swapped or changed last character.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Look up 200 LEIs per request
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;lei-records&lt;/code&gt; endpoint accepts a comma-separated &lt;code&gt;filter[lei]&lt;/code&gt;, so you don't need one request per code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.gleif.org/api/v1/lei-records?filter[lei]=LEI1,LEI2,...&amp;amp;page[size]=200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything valid that doesn't come back simply doesn't exist in the register.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The full script
&lt;/h2&gt;

&lt;p&gt;Put one LEI per line in &lt;code&gt;leis.txt&lt;/code&gt; and run &lt;code&gt;python3 lei_check.py leis.txt &amp;gt; result.csv&lt;/code&gt;. It only needs &lt;code&gt;requests&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.gleif.org/api/v1/lei-records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;LEI_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[A-Z0-9]{18}[0-9]{2}$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checksum_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ISO 17442 uses ISO 7064 MOD 97-10: letters become numbers (A=10 ... Z=35)
&lt;/span&gt;    &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leis&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch up to 200 LEI records in one request.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter[lei]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leis&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page[size]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;LEI_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;checksum_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# be polite to the free API
&lt;/span&gt;    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lei&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legal_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registration_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_renewal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lei&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lei&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid_format_or_checksum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;lei&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legalName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legalAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nextRenewalDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output for a small test file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;legal&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;entity&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;registration&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;next&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;renewal&lt;/span&gt;
&lt;span class="mf"&gt;5493001&lt;/span&gt;&lt;span class="k"&gt;KJTIIGC&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;Y&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="k"&gt;R&lt;/span&gt;&lt;span class="mf"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;Bloomberg&lt;/span&gt; &lt;span class="k"&gt;Finance&lt;/span&gt; &lt;span class="k"&gt;L&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;P&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;US&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ISSUED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2027-01-25&lt;/span&gt;
&lt;span class="mf"&gt;7&lt;/span&gt;&lt;span class="k"&gt;LTWFZYICNSX&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;D&lt;/span&gt;&lt;span class="mf"&gt;621&lt;/span&gt;&lt;span class="k"&gt;K&lt;/span&gt;&lt;span class="mf"&gt;86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;DEUTSCHE&lt;/span&gt; &lt;span class="k"&gt;BANK&lt;/span&gt; &lt;span class="k"&gt;AKTIENGESELLSCHAFT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ISSUED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2027-06-02&lt;/span&gt;
&lt;span class="k"&gt;HWUPKR&lt;/span&gt;&lt;span class="mf"&gt;0&lt;/span&gt;&lt;span class="k"&gt;MPOU&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;FGXBT&lt;/span&gt;&lt;span class="mf"&gt;394&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;Apple&lt;/span&gt; &lt;span class="k"&gt;Inc&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;US&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;ISSUED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2027-03-08&lt;/span&gt;
&lt;span class="mf"&gt;5493001&lt;/span&gt;&lt;span class="k"&gt;KJTIIGC&lt;/span&gt;&lt;span class="mf"&gt;8&lt;/span&gt;&lt;span class="k"&gt;Y&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="k"&gt;R&lt;/span&gt;&lt;span class="mf"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;invalid&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;format&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;or&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;,,,,,&lt;/span&gt;
&lt;span class="k"&gt;NOTANLEI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;invalid&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;format&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;or&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;,,,,,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Read the two status fields correctly
&lt;/h2&gt;

&lt;p&gt;They mean different things, and mixing them up is the most common mistake:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;entity.status&lt;/code&gt; is about the company: &lt;code&gt;ACTIVE&lt;/code&gt; or &lt;code&gt;INACTIVE&lt;/code&gt; (merged, dissolved).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;registration.status&lt;/code&gt; is about the LEI itself: &lt;code&gt;ISSUED&lt;/code&gt; is fine, &lt;code&gt;LAPSED&lt;/code&gt; means the renewal wasn't paid, &lt;code&gt;RETIRED&lt;/code&gt; or &lt;code&gt;ANNULLED&lt;/code&gt; means it shouldn't be used anymore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A company can be perfectly active with a lapsed LEI, which is exactly the case your compliance team wants flagged. The &lt;code&gt;next_renewal&lt;/code&gt; column tells you who is about to lapse.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Bonus: find an LEI from a company name
&lt;/h2&gt;

&lt;p&gt;If you only have names, the same endpoint filters by legal name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter[entity.legalName]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Siemens Aktiengesellschaft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page[size]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legalName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legalAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns both the German parent and the Austrian subsidiary, so always check the country before you trust a name match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The API is free but rate limited (around 60 requests per minute), so keep the small sleep between batches.&lt;/li&gt;
&lt;li&gt;GLEIF data is published under CC0, so you can store and reuse it.&lt;/li&gt;
&lt;li&gt;Parent and child relationships live under separate &lt;code&gt;relationships&lt;/code&gt; links on each record, if you need group structures.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not run it yourself
&lt;/h2&gt;

&lt;p&gt;Disclosure: I built and maintain a hosted version of this on Apify, &lt;a href="https://apify.com/tinyrex/gleif-lei-lookup" rel="noopener noreferrer"&gt;GLEIF LEI Lookup&lt;/a&gt;. It does the same checks plus name search and exports to CSV or Google Sheets, and invalid or not-found codes are free. For a one-off list, the script above is all you need.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>fintech</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Is your grocery pack getting smaller? A free unit price checker for Lidl, Carrefour, Albert Heijn, Mercadona, ICA and more</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:59:56 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/is-your-grocery-pack-getting-smaller-a-free-unit-price-checker-for-lidl-carrefour-albert-heijn-1627</link>
      <guid>https://dev.to/emirmrkaljevic/is-your-grocery-pack-getting-smaller-a-free-unit-price-checker-for-lidl-carrefour-albert-heijn-1627</guid>
      <description>&lt;p&gt;Shrinkflation is when a product keeps its price but the pack quietly gets smaller: 500 g becomes 450 g, 1 l becomes 900 ml. The price tag looks the same, so most people never notice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckort51dml8pt23ntuvd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckort51dml8pt23ntuvd.gif" alt="ShrinkWatch EU badge on a fictional demo product: unit price, then a shrinkflation warning" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;EU shops must show a unit price (per kg or per litre) next to the selling price under &lt;a href="https://eur-lex.europa.eu/eli/dir/1998/6/oj" rel="noopener noreferrer"&gt;Directive 98/6/EC&lt;/a&gt;. In practice it is often small, hidden, or missing in online shops, and it never tells you that the pack was bigger last month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ShrinkWatch EU does
&lt;/h2&gt;

&lt;p&gt;ShrinkWatch EU is a free, open-source extension for Chrome, Edge and Firefox. On a product page it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shows the price per kg, litre or piece in a small badge,&lt;/li&gt;
&lt;li&gt;cross-checks the shop's own unit price and tells you when they disagree,&lt;/li&gt;
&lt;li&gt;remembers pack sizes you have seen and warns you, for example "was 500 g, now 450 g, +11.1% per kg".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No account, no tracking, no network requests. Everything stays in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shops checked on real product pages
&lt;/h2&gt;

&lt;p&gt;Every shop below was opened in a normal desktop Chrome with the extension loaded, on a real product page, and the date and test product are logged:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Germany:&lt;/strong&gt; Lidl, Kaufland, Rewe, dm, Rossmann, Netto&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;France:&lt;/strong&gt; Lidl, Carrefour, Monoprix, Auchan&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netherlands:&lt;/strong&gt; Albert Heijn, Lidl&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spain:&lt;/strong&gt; Mercadona, Carrefour, Dia, Alcampo, Eroski, Lidl&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Italy:&lt;/strong&gt; Conad, Lidl&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poland:&lt;/strong&gt; Lidl, Carrefour, Auchan&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nordics:&lt;/strong&gt; ICA, Willys, Mathem (SE), Nemlig (DK), K-Ruoka (FI)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list (71 shops, 66 verified) with dates and test products is on the &lt;a href="https://github.com/emirmrkaljevic/shrinkwatch-eu/blob/main/docs/compatibility.md" rel="noopener noreferrer"&gt;compatibility page&lt;/a&gt;. Per-shop guides: &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/" rel="noopener noreferrer"&gt;https://emirmrkaljevic.github.io/shrinkwatch-eu/shops/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install in a minute
&lt;/h2&gt;

&lt;p&gt;Instructions and the demo are on the &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/#install" rel="noopener noreferrer"&gt;project page&lt;/a&gt;. Source and issues: &lt;a href="https://github.com/emirmrkaljevic/shrinkwatch-eu" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/shrinkwatch-eu&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your favourite shop is missing or shows a wrong figure, open an issue with the product link. That is how most fixes so far started.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and checked by me against the project's test logs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopping</category>
      <category>privacy</category>
      <category>opensource</category>
      <category>extensions</category>
    </item>
    <item>
      <title>A slanted wall and a slot into a hole: getting one more printer part to an editable script</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Tue, 27 Oct 2026 19:02:36 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/a-slanted-wall-and-a-slot-into-a-hole-getting-one-more-printer-part-to-an-editable-script-38gg</link>
      <guid>https://dev.to/emirmrkaljevic/a-slanted-wall-and-a-slot-into-a-hole-getting-one-more-printer-part-to-an-editable-script-38gg</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;mesh2cad&lt;/a&gt; turns an STL into CAD. Every part gets a STEP file with real planes, cylinders and cones. When the part is simple enough it also gets a CadQuery or OpenSCAD script with named parameters, so you can change &lt;code&gt;hole_3_d&lt;/code&gt; instead of editing triangles.&lt;/p&gt;

&lt;p&gt;I test on six real Original Prusa i3 MK3S parts. All six already come out as exact STEP. In &lt;a href="https://dev.to/emirmrkaljevic/why-my-stl-to-cad-script-was-8-mm-off-on-a-printer-part-and-the-fix-342p"&gt;the last post&lt;/a&gt; four of them had a script. 0.14.0 adds a fifth: y-rod-holder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was refused
&lt;/h2&gt;

&lt;p&gt;Last time I said y-rod-holder had two problems: a Ø6.3 opening stored as a partial ring of small planes, and a body that isn't a uniform extrusion. 0.13.x fixed the first one and the part went from 1.503 mm to 0.745 mm max deviation. The limit for this part is 0.138 mm, so it was still refused.&lt;/p&gt;

&lt;p&gt;The remaining error came from the outline. mesh2cad builds a script solid from outlines of the part, and it traced those outlines from the mesh. On this part that went wrong in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;there is a slot that breaks into the Ø8 rod bore, and the traced outline followed the bore round through the slot instead of stopping at the two edges where the slot meets the hole&lt;/li&gt;
&lt;li&gt;the -Y wall is slanted, and the traced outline cut straight across its corner instead of following it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither looks like much on a picture, but each one adds or removes a thin wedge of material along the whole part, and that is what the self-check measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;When both earlier approaches miss the gate, 0.14.0 tries a third rebuild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;each outline is rebuilt from a raster that is snapped to the vertical wall planes already found in the mesh, so a slanted wall stays slanted&lt;/li&gt;
&lt;li&gt;where a slot breaks into a hole, the outline is closed at the two mouths, and a flare that sits outside the cylinder is kept&lt;/li&gt;
&lt;li&gt;a planar 45 degree bevel with axis-aligned walls becomes a triangular cut (&lt;code&gt;cut_N&lt;/code&gt;) along the wide part of the face&lt;/li&gt;
&lt;li&gt;a leftover 45 degree cone becomes &lt;code&gt;cone_N_r0&lt;/code&gt;, &lt;code&gt;cone_N_r1&lt;/code&gt;, &lt;code&gt;cone_N_h&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;shallow floors are scored again on that solid and only kept if they bring it closer to the mesh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A part that already passes with a simpler solid never takes this path, so the other four parts are unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Every script is rebuilt and compared to the mesh before it is written. The gate hasn't changed: max deviation at most max(4 x tolerance, 0.1 mm), volume error at most 1%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;part&lt;/th&gt;
&lt;th&gt;exact STEP&lt;/th&gt;
&lt;th&gt;script (CadQuery / OpenSCAD)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plug-aligner&lt;/td&gt;
&lt;td&gt;0.0000 mm&lt;/td&gt;
&lt;td&gt;0.0000 / 0.0000 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;endstop-block&lt;/td&gt;
&lt;td&gt;0.0168 mm&lt;/td&gt;
&lt;td&gt;0.0421 / 0.0421 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-belt-idler&lt;/td&gt;
&lt;td&gt;0.0082 mm&lt;/td&gt;
&lt;td&gt;0.1534 / 0.1639 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-motor-holder&lt;/td&gt;
&lt;td&gt;0.1001 mm&lt;/td&gt;
&lt;td&gt;0.1843 / 0.1843 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;y-rod-holder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2178 mm&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.1300 / 0.1300 mm&lt;/strong&gt; (was refused at 0.745 mm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;y-belt-holder&lt;/td&gt;
&lt;td&gt;0.1602 mm&lt;/td&gt;
&lt;td&gt;refused (2.517 mm)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For y-rod-holder the volume error is 0.096% (CadQuery) and 0.089% (OpenSCAD). The Ø3.3 hole along Y keeps its Ø6.3 counterbore as named parameters.&lt;/p&gt;

&lt;p&gt;The new tests are small synthetic parts: a block with a 2 mm chamfer on the middle of one edge (it has to come out as &lt;code&gt;cut_1&lt;/code&gt; in both exporters), and a plate with a slanted wall and a slot into a hole (the outline has to keep the slanted corner and meet the hole at two mouths).&lt;/p&gt;

&lt;h2&gt;
  
  
  Still refused
&lt;/h2&gt;

&lt;p&gt;y-belt-holder is the last one. Its belt pocket is a B-spline profile and its cross-section changes along X. Wall-snapped outlines and bevel cuts can't follow a curve like that, and I'd rather it get no script than a script that is 2.5 mm off. It still gets exact STEP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; mesh2cad
mesh2cad convert part.stl &lt;span class="nt"&gt;-o&lt;/span&gt; part.step
mesh2cad convert part.stl &lt;span class="nt"&gt;--openscad&lt;/span&gt; part.scad
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also runs as an MCP server (&lt;code&gt;mesh2cad mcp&lt;/code&gt;), so an assistant can convert a part and change a parameter for you. If you have an STL of a simple mechanical part that fails, an issue with the file attached helps most: &lt;a href="https://github.com/emirmrkaljevic/mesh2cad" rel="noopener noreferrer"&gt;github.com/emirmrkaljevic/mesh2cad&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>3dprinting</category>
      <category>cad</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Ask for one site at a time: optional host permissions and dynamic content scripts in MV3</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:04:04 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/ask-for-one-site-at-a-time-optional-host-permissions-and-dynamic-content-scripts-in-mv3-3b7c</link>
      <guid>https://dev.to/emirmrkaljevic/ask-for-one-site-at-a-time-optional-host-permissions-and-dynamic-content-scripts-in-mv3-3b7c</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/emirmrkaljevic/shrinkwatch-eu" rel="noopener noreferrer"&gt;ShrinkWatch EU&lt;/a&gt; is an open source extension that shows the price per kg or litre on 71 European supermarket web shops. Asking for all of them at install time would mean more than 180 site patterns in the permission warning, and a harder store review. So only six shops are enabled at install. Every other shop is switched on by the user, one at a time, from the popup.&lt;/p&gt;

&lt;p&gt;This post is how that works in Manifest V3, and the three bugs I hit on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manifest
&lt;/h2&gt;

&lt;p&gt;The six default shops go in &lt;code&gt;host_permissions&lt;/code&gt; and a static &lt;code&gt;content_scripts&lt;/code&gt; entry. Everything else goes in &lt;code&gt;optional_host_permissions&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scripting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"activeTab"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://*.konzum.hr/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"optional_host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://*.rewe.de/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://*.lidl.fr/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;activeTab&lt;/code&gt; covers a "Check this page now" button that works on any site, once, without a standing permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking for the permission
&lt;/h2&gt;

&lt;p&gt;The request must run directly in the click handler, or the browser refuses to show the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;enable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;origins&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 1: asking for the wrong country.&lt;/strong&gt; Several shops share one adapter (Kaufland DE, CZ and SK; Tesco IE and UK). My first version asked for the first domain in the list, so on tesco.ie the prompt asked for tesco.com, and the content script never ran. The fix: ask only for the origins of the domain in the open tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering the content script at runtime
&lt;/h2&gt;

&lt;p&gt;A granted optional permission does nothing by itself. The background script registers one dynamic content script covering every granted shop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;optional-shops&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SCRIPTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;css&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/badge.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document_idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateContentScripts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerContentScripts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firefox does not keep dynamic scripts across browser restarts, so this sync also runs on every start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: "Duplicate script ID".&lt;/strong&gt; &lt;code&gt;onInstalled&lt;/code&gt;, &lt;code&gt;onStartup&lt;/code&gt;, &lt;code&gt;permissions.onAdded&lt;/code&gt; and the popup's message often fire at the same moment. Each one started its own register or unregister call, and they raced. Some failed with "Duplicate script ID" or "Nonexistent script ID", and sometimes no script was registered at all. The fix is a tiny queue so only one sync runs at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each sync reads the current granted permissions and makes the registration match them, so running it twice is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a shop off again
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: Disable did not really disable.&lt;/strong&gt; After &lt;code&gt;chrome.permissions.remove&lt;/code&gt;, clicking Enable again brought access back with no prompt. That is how Chrome works: a removed optional permission stays approved, so asking again is granted silently. To revoke it fully, the user has to use Site access on the extension's details page, and the popup now says so when removal fails.&lt;/p&gt;

&lt;p&gt;There was a second part: a tab opened while access was being removed could still show a badge from a stale registration. So on opt-in shops the content script now asks the background whether access is still granted before it shows anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;origins&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Yes. The install prompt lists six shops instead of 71, the extension never gets access to a site you don't shop on, and every piece above is covered by tests with a simulated &lt;code&gt;chrome&lt;/code&gt; object, run with &lt;code&gt;node --test&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website and install: &lt;a href="https://emirmrkaljevic.github.io/shrinkwatch-eu/" rel="noopener noreferrer"&gt;https://emirmrkaljevic.github.io/shrinkwatch-eu/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/emirmrkaljevic/shrinkwatch-eu" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/shrinkwatch-eu&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This article was written with AI assistance. The code, tests and examples come from the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webextensions</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>chrome</category>
    </item>
    <item>
      <title>Noticing when a smart device starts behaving differently</title>
      <dc:creator>Emir Mrkaljević</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:03:42 +0000</pubDate>
      <link>https://dev.to/emirmrkaljevic/noticing-when-a-smart-device-starts-behaving-differently-19n3</link>
      <guid>https://dev.to/emirmrkaljevic/noticing-when-a-smart-device-starts-behaving-differently-19n3</guid>
      <description>&lt;p&gt;A one-time audit of a smart plug or camera tells you what it does today. It says nothing about next month, after a firmware update or a change on the vendor's side. That gap is what I worked on in the last two releases of &lt;a href="https://github.com/emirmrkaljevic/iotaudit" rel="noopener noreferrer"&gt;iotaudit&lt;/a&gt;, a small open-source (MIT) tool I use to audit the devices on my own network.&lt;/p&gt;

&lt;p&gt;If you missed the earlier post about the per-device grade and the one-page report, it is &lt;a href="https://dev.to/emirmrkaljevic/a-one-page-safety-report-for-each-smart-device-on-your-network-41c8"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Everything below runs locally. There is no telemetry, no account, and it is meant only for your own network and devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing two audits
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;capture&lt;/code&gt; and &lt;code&gt;analyze&lt;/code&gt; already save a JSON report. Since 0.1.9 you can compare two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; iotaudit

iotaudit diff yesterday.json today.json
iotaudit diff yesterday.json today.json &lt;span class="nt"&gt;--json&lt;/span&gt;

&lt;span class="c"&gt;# or print the diff right after a new analysis&lt;/span&gt;
iotaudit analyze capture.pcap &lt;span class="nt"&gt;--device&lt;/span&gt; 192.168.1.50 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--json&lt;/span&gt; today.json &lt;span class="nt"&gt;--out&lt;/span&gt; today.html &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--compare&lt;/span&gt; yesterday.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Devices match by MAC, then by IP. For each device it lists new and gone destination domains and IPs, new countries, new open ports, grade changes, verdict changes, newly seen unencrypted traffic, and new UPnP forwards when the JSON has them.&lt;/p&gt;

&lt;p&gt;Here is a made-up example of the kind of output you get (invented device and addresses, not from a real network):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXAMPLE OUTPUT (made up)
device 02:00:00:00:00:01 (192.168.1.50)
  grade: B -&amp;gt; D
  new domains: telemetry.example.net
  new IPs: 198.51.100.23
  new countries: SG
  new open ports: 23/tcp
  new unencrypted traffic: HTTP to 198.51.100.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is the part I care most about. It is &lt;strong&gt;1&lt;/strong&gt; when something security relevant appeared: a new device, destination, country, open port, a worse grade, unencrypted traffic, or a UPnP forward. It is &lt;strong&gt;0&lt;/strong&gt; otherwise, including when things only went away or the grade got better. A verdict change is printed but does not exit 1 on its own. A missing or unreadable file exits &lt;strong&gt;2&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That makes it easy to put in cron and only hear about it when something new shows up. This is the script from the README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# 15 6 * * 1-5 "$HOME/iotaudit/check.sh"&lt;/span&gt;
&lt;span class="nv"&gt;report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;iotaudit diff &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/iotaudit/yesterday.json"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/iotaudit/today.json"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same comparison is available to MCP clients as the &lt;code&gt;diff_results&lt;/code&gt; tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline known issue hints
&lt;/h2&gt;

&lt;p&gt;0.1.10 adds a small curated list of public CVEs (14 entries right now) that is shipped inside the package. It is matched against version strings iotaudit already sees: nmap service versions, HTTP &lt;code&gt;Server&lt;/code&gt; headers, UPnP and SSDP headers. Think MiniUPnPd, Dropbear, lighttpd, BusyBox, RomPager, the usual embedded suspects.&lt;/p&gt;

&lt;p&gt;Each match shows up as &lt;code&gt;known_issue_hints&lt;/code&gt; in the JSON and in the HTML/PDF report, with a link to the public NVD page. I was careful about the wording: every hint says the issue is &lt;em&gt;possible&lt;/em&gt; based on the version string, and that you still need to check the actual firmware. Vendors backport fixes without changing banners all the time.&lt;/p&gt;

&lt;p&gt;For the same reason, a hint only changes the grade when the observed version matches a listed version exactly &lt;em&gt;and&lt;/em&gt; the entry is critical. Version ranges and lower severities stay as hints. There is no network lookup at runtime and no update command; the list is whatever shipped with the release.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like to hear
&lt;/h2&gt;

&lt;p&gt;If you run it against your own devices and the diff is noisy or a hint looks wrong, an issue on GitHub helps a lot. Suggestions for well documented CVEs to add to the list are welcome too.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/emirmrkaljevic/iotaudit" rel="noopener noreferrer"&gt;https://github.com/emirmrkaljevic/iotaudit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>security</category>
      <category>python</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
