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.
Try it in Chrome or Edge: https://iscarelli.github.io/niimbot-web-bluetooth/demo/ Code, MIT, no dependencies: https://github.com/iscarelli/niimbot-web-bluetooth
Seven printers are validated on real hardware now: B1, B1 Pro, B2 Pro, M2-H, D11H, 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. The write that vanishes
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.
What you see is a label that comes out short, or blank, while the progress callback climbs to 100% and the console stays green.
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.
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.
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. The datasheet is not a measurement
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.
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.
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.
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: probe(0xdc, [0x03]) answers with the width the printer believes it has. On a D11H 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. Two printers that speak the same protocol do not behave the same
This is the one that cost the most, because it looks like a solved problem twice.
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.
The D110 and the N1 accept that request. They ack the page count. They ack copies=3. Then they print one label.
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 pagesPerJob 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.
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.
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. Saying "I did not measure this" is a feature
