<?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: Ismael Carelli</title>
    <description>The latest articles on DEV Community by Ismael Carelli (@iscarelli).</description>
    <link>https://dev.to/iscarelli</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%2F3972841%2Fedee8a31-345d-43ea-9f4d-6099808168c6.jpeg</url>
      <title>DEV Community: Ismael Carelli</title>
      <link>https://dev.to/iscarelli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iscarelli"/>
    <language>en</language>
    <item>
      <title>The label printer says 300 dpi. The paper says 203.</title>
      <dc:creator>Ismael Carelli</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:56:55 +0000</pubDate>
      <link>https://dev.to/iscarelli/the-label-printer-says-300-dpi-the-paper-says-203-550j</link>
      <guid>https://dev.to/iscarelli/the-label-printer-says-300-dpi-the-paper-says-203-550j</guid>
      <description>&lt;p&gt;Niimbot label printers cost about as much as a pizza and print stickers over Bluetooth. The catch is the phone app: to print a label you install their software, and your label passes through it. I wanted to print from a web page instead, so I reverse-engineered the protocol and wrote a Web Bluetooth driver that runs entirely in a browser tab.&lt;/p&gt;

&lt;p&gt;Try it in Chrome or Edge: &lt;a href="https://iscarelli.github.io/niimbot-web-bluetooth/demo/" rel="noopener noreferrer"&gt;https://iscarelli.github.io/niimbot-web-bluetooth/demo/&lt;/a&gt;&lt;br&gt;
Code, MIT, no dependencies: &lt;a href="https://github.com/iscarelli/niimbot-web-bluetooth" rel="noopener noreferrer"&gt;https://github.com/iscarelli/niimbot-web-bluetooth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Seven printers are validated on real hardware now: B1, B1 Pro, B2 Pro, M2-H, D11_H, D110 and N1. What follows is not a tour of the API. It is the four things the hardware taught me that I would not have believed from reading a protocol dump.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The write that vanishes
&lt;/h2&gt;

&lt;p&gt;Every row of the bitmap goes out as one BLE write without response. That is the fast path, and on most platforms it is correct. It also has a failure mode that no amount of logging catches: if the stack drops the write, nothing happens. There is no error, no missing ack to notice, no timeout to trip. The bytes are simply not there.&lt;/p&gt;

&lt;p&gt;What you see is a label that comes out short, or blank, while the progress callback climbs to 100% and the console stays green.&lt;/p&gt;

&lt;p&gt;This shipped twice, in two consecutive releases, before I understood what I was looking at. And through version 1.4.0 the print promise resolved whether or not the printer confirmed anything, which is how a run that produced 4 labels out of 5 reported success to the caller.&lt;/p&gt;

&lt;p&gt;Two fixes came out of it. The driver now watches the printer's own printed-page counter and rejects if it never reaches the total, so an unconfirmed job fails loudly instead of quietly. And macOS gets a pacing gap between writes unconditionally, because macOS drops unacked bursts regardless of which printer is attached. That check turned out to cover iOS for free: every iOS user agent contains the string "like Mac OS X", so an iPhone matches the same test.&lt;/p&gt;

&lt;p&gt;The part I actually changed my mind about is the documentation. The project's own rules file now says that a print reporting success is not a print, and that mechanical verification (syntax, harnesses, reading the code) is never allowed to claim a print path works. Only paper can say that.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The datasheet is not a measurement
&lt;/h2&gt;

&lt;p&gt;NIIMBOT sells the N1 as a 300 dpi printer. It is not.&lt;/p&gt;

&lt;p&gt;The test that settled it was a ruler: an image with numbered rows, printed on a 14 x 50 mm label. It came out truncated after row 350, and row 350 landed about 45 mm down the label. That is roughly 7.8 pixels per millimetre. 203 dpi predicts 7.99. At 300 dpi, row 350 would have sat 29.6 mm down and left the bottom third of the label blank.&lt;/p&gt;

&lt;p&gt;The community wiki at MultiMote/niimbot-wiki already listed the N1 at 203, so I confirmed a number rather than discovering one. What I want to keep from the episode is the shape of the test, because my first attempts were worse and I could not see why.&lt;/p&gt;

&lt;p&gt;A test that asks whether the image fit tells you almost nothing, because "it did not fit" has several causes: the dpi is wrong, the head is narrower than you assumed, the offset is off, the printer clipped. A test that asks where a specific mark landed has one reading. The row number is printed inside the image, so the label itself tells you which row you are looking at. That distinction is worth more than the dpi finding.&lt;/p&gt;

&lt;p&gt;The same reasoning fixed the printhead widths. You can derive a head width from the label size and the dpi, and you will be wrong on at least three of these models. Or you can ask: &lt;code&gt;probe(0xdc, [0x03])&lt;/code&gt; answers with the width the printer believes it has. On a D11_H that is 144 pixels for a 15 mm label, so about 1.4 mm on each side never prints. Send a wider image and the extra columns are dropped with no error at any layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two printers that speak the same protocol do not behave the same
&lt;/h2&gt;

&lt;p&gt;This is the one that cost the most, because it looks like a solved problem twice.&lt;/p&gt;

&lt;p&gt;For N identical labels, the protocol lets you declare the page count up front, upload the bitmap once, and let the printer repeat it internally. Much faster than re-sending. It works on the B1, the B1 Pro, the B2 Pro and the M2-H.&lt;/p&gt;

&lt;p&gt;The D110 and the N1 accept that request. They ack the page count. They ack &lt;code&gt;copies=3&lt;/code&gt;. Then they print one label.&lt;/p&gt;

&lt;p&gt;They also speak the same command sequence as the B1 and the M2-H, which do pipeline pages correctly, so there is no protocol-level property to key the behaviour off. It is per model, full stop. The driver now carries a &lt;code&gt;pagesPerJob&lt;/code&gt; field on those two entries and splits the call into three complete jobs, which costs the upload three times instead of once: measured on a D110 with a 264-row image, 3 copies took 18 seconds, of which one upload is about 3.2 seconds.&lt;/p&gt;

&lt;p&gt;Flow control has the same shape. The 203 dpi B1 drops rows if you burst at it unpaced. The 300 dpi models take the burst fine. The M2-H advertises write-with-response, and using it made every row a round trip, turning a 2 second page into a 30 second page.&lt;/p&gt;

&lt;p&gt;The general version: when a device family shares a protocol, the protocol tells you the grammar and not the behaviour. Behaviour is per unit, and you find it by printing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Saying "I did not measure this" is a feature
&lt;/h2&gt;

&lt;p&gt;The driver exposes the printer's consumable status: lid open, paper inserted, charge level, the RFID data on the roll. That readout is easy to decode and easy to get wrong, because lid polarity is inverted on some models and half those fields were inferred from another project's source rather than seen moving.&lt;/p&gt;

&lt;p&gt;So the status call returns per-field evidence alongside the values. Each field is marked &lt;code&gt;observed&lt;/code&gt; (it moved on hardware here, doing exactly what its name says), &lt;code&gt;varies&lt;/code&gt; (it moved, but what it measures is unsettled), or &lt;code&gt;inferred&lt;/code&gt; (not confirmed here at all). An unrecognised payload comes back as &lt;code&gt;confidence: "unknown"&lt;/code&gt; instead of being half-decoded into plausible-looking numbers. And nothing in the driver ever acts on any of it: gating a print is the application's decision.&lt;/p&gt;

&lt;p&gt;This felt like over-engineering while I was writing it, and it is the API I would keep if I could keep only one. Every field that lies to you costs someone a debugging session. A field that says "I am a guess" costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like to use
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"src/niimbot.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name_prefixes&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="s2"&gt;B1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;density&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="na"&gt;label_type&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="na"&gt;speed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;w_px&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h_px&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;offset_y_px&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// 50x30 mm on a B1&lt;/span&gt;

  &lt;span class="p"&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="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;Niimbot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&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;Niimbot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;printImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/label.png&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;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;copies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No build step, no dependencies, one file. The model and size objects come from &lt;code&gt;registry.json&lt;/code&gt;, and &lt;code&gt;Niimbot.identify()&lt;/code&gt; reads the model id off the printer so an app can select the right pair instead of trusting the user. The B1 and B1 Pro advertise the same Bluetooth name, so you cannot tell them apart from the chooser.&lt;/p&gt;

&lt;p&gt;Web Bluetooth means Chrome, Edge and Opera on desktop and Android. Firefox has none, Safari has none, and Apple has said it is not coming. On an iPhone, Bluefy works: I printed from one on a B1 Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credit, and the thing I want back
&lt;/h2&gt;

&lt;p&gt;niim.blue and niimbluelib by MultiMote were my reference the whole way through, and the community wiki is where the model table lives. If you want a full label designer, use theirs. Mine is deliberately narrower: a driver you drop into a page you already have.&lt;/p&gt;

&lt;p&gt;What I would like in return is model reports. Nine or ten Niimbot models share these two protocol families and I have seven of them. The demo prints the model id it reads on connect, and there is a bring-up harness in &lt;code&gt;test/bringup.mjs&lt;/code&gt; that runs from the demo page's console and walks through the tests above, one call each. If you own a B21, a D11, a B21S or a D110_M, that is about ten minutes and two labels, and it is the only way the table gets filled in.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>bluetooth</category>
      <category>reverseengineering</category>
    </item>
  </channel>
</rss>
