The thermal printer that taught me to measure instead of infer
In the previous post I got a Bluetooth receipt printer talking to a Cloudflare Worker. That was the easy half. This is the half where I repeatedly reasoned my way to a wrong answer about a piece of hardware sitting two feet away, when I could have measured it in ninety seconds.
The one number everything hangs off#
The print head is 384 dots wide at 203 dpi. That works out to 48.05mm — and “roughly 48mm” is not good enough, because if the rasteriser hands over an image that isn’t exactly 384 dots the driver resamples it, and resampled 1-bit text at this size turns to mush.
The fix is a rendering resolution that isn’t the printer’s spec:
pdftoppm -r 203.2 -png -gray receipt.pdf outAt 203.2 dpi you get exactly 8 dots per millimetre, so a 48mm page rasterises to exactly 384 dots and nothing resamples. At 203 you get 7.992, a 48mm page lands on 383.6, and every glyph goes through an interpolator for no reason.
The second detail is the same shape. The library’s image path calls .convert("1"), which is a Floyd–Steinberg dither. Dithering is right for photographs and wrong for text: antialiased glyph edges are about 8% grey, and the dither stipples every stroke into dots. Flattening to pure black and white first makes the dither a no-op and keeps thin strokes solid:
im.point(lambda p: 0 if p < 176 else 255, 'L')Both of these I got right early, which set me up nicely to get the next part wrong for a week.
Eight rounds of guessing at the margins#
The first prints came out with uneven left and right margins. So I adjusted the page width. It moved the wrong way. So I adjusted it again, and again, each time inferring the head’s geometry from how the last print looked.
Every new print contradicted the previous theory. I measured the black calibration bar and found 4mm of white on the left and 3mm on the right, and built a model around that. I misread my own calibration ruler — the printed scale stopped at 50 because that was the last label I’d drawn, and I read it as the head’s limit.
Eight rounds. Then the answer arrived from the person actually holding the printer:
i choosed 48mm as paper size and fit to paper and printed it is fine now
The phone app scales the page to the head. A page that already measures 48mm therefore scales 1:1 and every millimetre reaches paper. Nothing about the head was wrong; the print dialog was rescaling my carefully-computed geometry. My first configuration had been the best one all along, and I’d spent eight iterations tuning away from it.
The lesson is not “ask the human.” It’s that I was fitting a model to symptoms while an unexamined step sat in the middle of the pipeline. Every datapoint I collected was real, and every conclusion was wrong, because the pipeline had a scaling stage I hadn’t accounted for.
The stutter, and the fix that sounds backwards#
The prints worked but stuttered — the head would pause mid-receipt, leaving faint horizontal bands.
The cause is a rate mismatch. The BLE link feeds data at roughly 18KB/s, because the bridge forces write-with-response and you therefore get one write per connection interval. The head, left alone, prints faster than that. It runs out of data, pauses, resumes, and each pause leaves a band.
You cannot make BLE faster. So you make the head slower:
# ESC 7 n1 n2 n3 — max heating dots, heating time, heating interval
HEAT_DOTS = 3 # fewer dots heated at once
HEAT_TIME = 120
HEAT_INTERVAL = 40 # longer gap between strobesSlowing the print head below the feed rate trades pages-per-minute for a continuous, band-free print. It felt wrong to type — the fix for “too slow” is “make it slower” — but the goal was never speed, it was a head that never starves.
The bug I blamed on the wrong command#
Now the part I actually want on the record.
While chasing the stutter I also tried ESC j, the reverse-feed command, to claw back the paper fed past the tear bar. The next print came out as dense unreadable glyphs — the reporter’s words were “looks like chinese language.”
I had a tidy explanation ready. Reverse feed is optional in ESC/POS. If this unit doesn’t implement it, the parameter byte isn’t consumed, falls through as raster data, shifts every subsequent row, and turns the print into garbage. That story fits the symptom perfectly. I removed the command and wrote a warning into the source so nobody would reintroduce it:
There is deliberately no reverse-feed option. ESC j was tried and corrupted every print after it… the parameter byte was not consumed and fell through as raster data.
Confident, specific, and wrong.
Because in the same session I had also been changing the BLE pacing — dropping the inter-write delay from 60ms to 20ms — and that produced the identical symptom. Two independent changes, one collapsed-text failure mode, and I attributed it to the more interesting suspect without isolating either.
Weeks later, someone mentioned the official app pulls the paper back before printing. That contradicted my note, so I finally ran the experiment I should have run first — in text mode, not raster:
LINE-A
(3 blank lines) ← reference gap
LINE-B
(3 blank lines) + ESC j n ← same gap, with the reverse feed inside it
LINE-CText mode is the whole trick. If the parameter byte really does fall through, in text mode it prints as one stray character — visible, harmless, unmistakable. In a raster job the same byte destroys the entire print, which is why my original evidence was useless: total corruption looks the same no matter what caused it.
The result: the two gaps came out identical, with no stray characters anywhere. The printer consumes ESC j and its parameter and then does nothing at all. Silently ignored — not unsupported, not dangerous, just inert. My warning comment had been libelling the wrong command for weeks. It now records what the probe actually showed, along with the real culprit.
The generalisable bit: when two changes land together and one symptom appears, you have zero information about which caused it. I felt like I had a diagnosis because I had a mechanism — a plausible story about byte consumption. A mechanism is not evidence. And the cost of getting it wrong wasn’t just the wrong fix; it was a comment in the source that would have stopped the next person from checking.
Two calibration prints, both designed to fail#
Having decided to actually measure things, I designed two probes. Both were broken, in ways that are obvious in hindsight and instructive in kind.
Probe 1 ended on a marker line and fed nothing afterwards, so the last printed row would stop exactly under the head — measure from that line to the paper exit and you have the head-to-tear-bar distance. Sound idea. I then printed probe 2 immediately after it, which advanced the paper and destroyed the one gap probe 1 existed to measure. The instrument consumed its own reading.
Probe 2 printed LINE-A, a gap, then LINE-B, with two trailing blank lines. The head sits about 15mm behind the tear bar, and two lines is about 8mm — so LINE-B never emerged from the machine. The response was, reasonably, “i only see till LINE-A.”
Both faults are the same fault: I designed the experiment around the thing I wanted to learn and not around the physical act of reading it. A measurement that gets overwritten before it’s read, and an output that never leaves the box, are not measurements.
Fixed: probe 2 first with ten trailing lines and a built-in reference gap so the answer is a comparison between two gaps on one strip rather than a judgement about one; probe 1 last, with nothing after it.
The ruler that ended the guessing#
Even fixed, probe 1 gives you an answer in lines, and line height is another thing I’d have had to infer. So instead of inferring, I printed a ruler.
A 384-dot-wide image, 45mm tall, with a tick every millimetre and a label every five — where every label states its own distance from the bottom row of the image. Print it with zero feed afterwards, and that bottom row stops exactly under the head. Whatever number sits at the paper exit is the head-to-tear distance. No line-height arithmetic, no inference, no chain of reasoning to get wrong. At 203.2 dpi one dot is exactly 0.125mm, so the geometry is exact by construction.
The answer came back in one word: 15mm.
That single number then determined everything downstream, and the whole paper-waste problem collapsed into arithmetic.
Feeding paper with rows, not commands#
Each receipt was costing far more paper than its content. Three sources:
- ~6mm of white margin at the top of the rendered PDF, and ~6mm at the bottom
- three newlines plus
ESC d 3after the raster — six line feeds, about 25mm - the structural 15mm between head and tear bar, which is unavoidable
The first is free to remove: crop the raster to its ink bounds. A blank row costs exactly as much paper and BLE time as a printed one, and rows with no black pixels cannot change what appears.
The second is where the interesting decision is. I needed to advance the paper by a precise 18mm — 15mm to reach the tear bar, plus 3mm so the tear doesn’t clip the last line. The obvious tool is a feed command. I used blank raster rows instead:
pad = round((head_to_tear + bottom_margin) * DOTS_PER_MM) # exactly 8 dots/mm
out = Image.new('L', (im.width, im.height + pad), 255)
out.paste(im, (0, 0))Because ESC d and ESC j are both optional in ESC/POS, and I had just spent a week learning that this unit silently ignores one of them. A feed command is a request the printer is free to decline. A blank row is not optional — it advances the paper by exactly 0.125mm, through the one mechanism I had verified to the dot. It costs about 1.8 seconds of BLE time per receipt, and buys exactness.
Result on a representative receipt: 109mm down to 90mm, and the visible gap between consecutive prints roughly halved. The 15mm of leading blank is structural and stays — reclaiming it needs a working retract, and this printer ignores the standard one.
A misdiagnosis that cost someone else’s time#
One more, because it’s the kind of mistake that’s easy to leave out of a write-up.
Prints started failing to connect. I concluded the hardware was unhappy and asked for a power cycle of the printer, then the bridge. Neither helped, which I took as confirmation of something deeper.
The actual cause was mine: seven orphaned esphome logs containers I’d left running, each holding an API connection, which had exhausted the bridge’s client limit. The device was refusing new connections because I had used them all up.
I’d sent someone to physically power-cycle two devices to fix a problem I had created and could have found with one docker ps. Check what you left running before you blame the hardware — especially when you’re the only one who’s been touching it.
Takeaway#
Every wrong turn here has the same shape. I had a plausible mechanism and treated it as a finding.
The margins had a model built on symptoms while an unexamined scaling step sat in the pipeline. ESC j had a beautiful, wrong story about byte consumption — written into the source as a warning, where it would have stopped the next person from checking. The probes were designed around what I wanted to learn instead of how the reading would physically be taken. The connection failures had a hardware theory that survived exactly as long as it took to run docker ps.
What ended it every time was the same move: produce a reading the wrong theory cannot also produce. The ruler whose bottom row is the last printed row. The reverse-feed probe in text mode, where an unconsumed byte shows up as one stray character instead of total corruption. A reference gap on the same strip as the test gap.
Software lets you get away with inference for a long time, because you can read the state. Hardware doesn’t. The printer only ever told me one thing — what came out on the paper — and for a week I kept asking it questions it had no way to answer.