Tech 3 min read

Sugar YMP-01 thermal printer on M1 Mac: halftone + 1D 49 F0 nn density command

IkesanContents

I wrote a previous post on driving the Sugar mini printer from a PC. It got me to the point where I could send ESC/POS commands over BLE and print text and small images, but compared to the same photo printed from the official Sugar app (WalkPrint), the image quality was completely different — my output came out blurry and oddly faint.
Separately I was deep in the middle of an RFCOMM rework of the HP Sprocket late at night, the topic of halftone preprocessing came up there, and I realized the same trick should apply to the Sugar. One thing led to another and this became a sequel.

End state:

  • Floyd-Steinberg dithering to convert photos into something a thermal head can actually render
  • BLE write-without-response at around 5 KB/s
  • Identified a vendor-specific density command 1D 49 F0 nn from observing the official WalkPrint app’s behavior
  • Python client that paces correctly and feeds enough paper to clear the tear bar

Notes on the rabbit holes I fell into along the way.

Preprocessing for thermal photo printing: halftone

A thermal printer’s output is strictly binary — each dot is either fired or not. If you binarize a grayscale photo naïvely with > 128 ? 0 : 1, all the mid-tones collapse and you end up with faces rendered as solid black blobs.
The classic answer (used in newspapers for decades) is halftoning: convert mid-tones into spatial patterns of black dots whose local density approximates the gray value.

Two common approaches:

MethodBehaviorSuitability for thermal
Clustered-dotThreshold matrix where ink dots grow from cluster centers outward. Newspaper-photo aestheticClassic; very even grain
Floyd-SteinbergError diffusion — quantization error propagates to neighboring pixels. Noisy but preserves detailBest for detail recovery

I implemented both in Python and compared previews.

import numpy as np
from PIL import Image, ImageOps

CLUSTERED = np.array([
    [24,10,12,26,35,47,49,37],
    [ 8, 0, 2,14,45,59,61,51],
    [22, 6, 4,16,43,57,63,53],
    [30,20,18,28,33,41,55,39],
    [34,46,48,38,25,11,13,27],
    [44,58,60,50, 9, 1, 3,15],
    [42,56,62,52,23, 7, 5,17],
    [32,40,54,36,31,21,19,29],
], dtype=np.float32)
CLUSTERED = (CLUSTERED + 0.5) * (256.0 / 64.0)

def clustered_halftone(gray_np):
    h, w = gray_np.shape
    tile = np.tile(CLUSTERED, (h//8+1, w//8+1))[:h, :w]
    return (gray_np > tile).astype(np.uint8) * 255

def floyd_steinberg(gray_np):
    a = gray_np.astype(np.float32).copy()
    h, w = a.shape
    for y in range(h):
        for x in range(w):
            old = a[y, x]
            new = 255.0 if old >= 128 else 0.0
            a[y, x] = new
            err = old - new
            if x+1 < w: a[y, x+1] += err * 7/16
            if y+1 < h:
                if x > 0:   a[y+1, x-1] += err * 3/16
                a[y+1, x] += err * 5/16
                if x+1 < w: a[y+1, x+1] += err * 1/16
    return np.clip(a, 0, 255).astype(np.uint8)
Clustered-dot preview (newspaper style)Floyd-Steinberg preview (detail recovery)
Clustered-dot halftone previewFloyd-Steinberg halftone preview

On actual prints, Floyd-Steinberg recovers the face shadows and even small text (the “LIVE PAUNI” T-shirt logo). Clustered-dot has a retro newspaper-photo feel that’s nice in its own way, but fine detail tends to mush. I went with FS for the rest of the experiments.

BLE pacing

The Sugar exposes the ISSC Transparent UART service:

Service:  49535343-fe7d-4ae5-8fa9-9fafd205e455
Write:    49535343-8841-43f4-a8d4-ecbe34729bb3 (write, write-without-response)
Notify:   49535343-1e4d-4bd9-ba61-23c647249616 (read, notify)

First I tried just hosing all the data into write_without_response back to back:

PacingResult
26 KB/s (no delay)Only the top of the image prints, then the printer stops (buffer overflow)
400 B/s (too slow)Print head stutters — pft, pft, pft — with white horizontal gaps cutting through the image
5 KB/s (200B / 40ms)Completes cleanly, image looks correct

26 KB/s send — only the top of the head printed before buffer overflow stopped the printer

400 B/s send — horizontal white gaps from the print head pausing between bursts

Thermal printers need flow control paced by the print head, not by the radio link. BLE write-without-response has no per-write ACK, so the radio side is fast and dumb; whatever exceeds the printer’s small internal buffer (a few KB) is silently dropped.
Conversely, if data arrives slower than the head consumes it, the head pauses waiting for the next chunk, the stepper motor keeps advancing paper, and you get visible white bands. The sweet spot is close to the head’s natural throughput, slightly above it at most.

Standard ESC/POS density commands all do nothing here

The Sugar’s default print comes out light. WalkPrint can clearly make it darker, so there must be some density-setting byte sequence. I tried every plausible ESC/POS density command I knew about:

Command triedOriginResult
ESC 7 n1 n2 n3 (heating time)Phomemo-style vendor extensionCompletely ignored
51 78 A4 00 01 00 nn ck FF (Quality)Cat-Printer / iPrint familyPrinter stops (protocol gets confused)
10 FF 10 00 nn (DLE FF density)DP-L13 style real-time commandNothing prints

There’s no standardized “print density” command in ESC/POS itself — every manufacturer defines their own. The Sugar belongs to none of the families above.

Identifying the density command by observing the official app

When WalkPrint prints the same image, it’s visibly darker. So the app must be sending some density-setting command before the image data. I focused on GS I-family vendor sub-functions and worked through likely candidates on the printer, and 1D 49 F0 nn (GS I 0xF0 nn, with nn being the density value) turned out to change the print darkness.

Measured behavior of the density parameter:

nn (decimal)hexSubjective darkness
0x0A (10)default-equivalentlight
0x0C (12)“new” preset lightslightly light
0x0F (15)“old” dark / “new” mediummedium
0x12 (18)“new” preset darkdark
0x14 (20)“public” preset lightdark
0x1E (30)“public” preset dark / maxvery dark

The printer accepts values in the 5–30 range. Going above 30 either gets ignored or is clamped internally by firmware.
There’s also a paired speed command 1D 49 F1 nn, where larger nn means faster paper feed (= lighter prints). The official app sends density and speed together to compose the final print quality.

Print without any density command (firmware default) — readable but faint

Higher density slows down the printer

Bumping to density 30 at 5 KB/s caused the print to stop about a third of the way down the image (around the neck).
The reason is simple: at higher density the thermal head dwells longer per dot, so its effective throughput drops. 5 KB/s is now too fast and the buffer overflows again.

Empirically-measured stable pacing per density level:

Density valuePacing that workedComplete
10 (default)5 KB/s✓ (light)
18 (“new” dark)~3 KB/s✓ (medium)
30 (“public” dark / max)2 KB/s✓ (dark)

Slow the pacing as you raise the density — avoid both buffer overflow and head-stalling at the same time.

Trailing paper feed

The thermal head and the tear bar sit roughly 25–35 mm apart on most of these mini printers. Right after a print completes, the bottom of the image is still inside the printer, behind the tear bar.

I tried ESC J FF (1B 4A FF, = 255 × 1/8 mm ≈ 32 mm), but firmware appears to cap or partially ignore it — the image only made it as far as the chest before the paper stopped advancing.
ESC d 30 (a 90 mm line-based feed) plus 20 trailing line feeds as insurance reliably pushes the print past the tear bar:

final_feed = bytes([0x1B, 0x64, 0x1E])  # ESC d 30
lfs = bytes([0x0A] * 20)
payload = density + init + header + raster + final_feed + lfs

Final form

import asyncio, time, struct
import numpy as np
from PIL import Image
from bleak import BleakClient

ADDR = "..."  # YMP-01 BLE address
WRITE_CHAR = "49535343-8841-43f4-a8d4-ecbe34729bb3"

# halftone-binarize image and format as ESC/POS bitmap
img = Image.open('photo.jpg').convert('L')
img = ImageOps.autocontrast(img, cutoff=2)
img.thumbnail((384, 9999), Image.LANCZOS)
gray = np.asarray(img)
bits = floyd_steinberg(gray)
bits = (bits < 128).astype(np.uint8)
# 1bpp packed
raster = bytearray()
for y in range(bits.shape[0]):
    for x in range(0, bits.shape[1], 8):
        b = 0
        for i in range(8):
            if bits[y, x+i]: b |= (1 << (7-i))
        raster.append(b)

W = bits.shape[1]; H = bits.shape[0]
density = bytes([0x1D, 0x49, 0xF0, 0x1E])          # WalkPrint density 30 (max)
init    = bytes([0x1B, 0x40])                      # ESC @
header  = bytes([0x1D, 0x76, 0x30, 0x00,           # GS v 0
                 (W//8) & 0xff, ((W//8)>>8) & 0xff,
                 H & 0xff, (H>>8) & 0xff])
feed    = bytes([0x1B, 0x64, 0x1E]) + bytes([0x0A]*20)
payload = density + init + header + bytes(raster) + feed

async def main():
    async with BleakClient(ADDR, timeout=20.0) as c:
        CHUNK = 200; SLEEP = 0.10  # 2 KB/s, tuned for max density
        for i in range(0, len(payload), CHUNK):
            await c.write_gatt_char(WRITE_CHAR, payload[i:i+CHUNK], response=False)
            await asyncio.sleep(SLEEP)
        await asyncio.sleep(3)

asyncio.run(main())

Final print: density 30 + 2 KB/s pacing. Comparable darkness to the official app, fully out past the tear bar

With halftone preprocessing + density 30 + the right pacing, the output is essentially indistinguishable from WalkPrint’s by eye.

The continuous test strip from tweaking density and pacing without tearing between attempts:

Test strip — same image printed with different density and pacing settings, left un-torn