A generic driver for ESC/POS thermal receipt and label printers, over RS-232 or TTL serial through a Blocks Player, or raw TCP on port 9100. It targets the portable core of ESC/POS, the commands Epson and the major clone controllers document identically, so it should work on more or less any ESC/POS printer rather than being tuned to one brand. The driver transcodes text into the printer's selected code page, reads paper and error status back as properties, and prints PNG and JPEG images straight from a block's media.
| Transport | Network Device type | Typical use | Feedback |
|---|---|---|---|
| RS-232 or TTL serial through a Player | Serial | A printer at a spot, on a USB-serial adaptor | Yes, where the printer's TX is wired back to the host's RX |
| Raw TCP, port 9100 | TCP | A network printer, the JetDirect/RAW convention | Yes |
| Paper | Dots per line | Font A (12x24) | Font B (9x17) | Max image width |
|---|---|---|---|---|
| 58 mm | 384 | 32 characters | 42 characters | 48 bytes |
| 80 mm | 576 | 48 characters | 64 characters | 72 bytes |
Developed and verified against a Donnwei EM5822H 58 mm printer on the bench, over a PL2303-GC USB-serial adaptor at 115200 baud, September 2026.
script/driver/ESC_POS_Printer.ts
If this document conflicts with the source code, the header comment in script/driver/ESC_POS_Printer.ts takes precedence.
For a printer on a Player's serial port, create a Serial Network Device:
For a network printer, create a TCP Network Device:
9100The driver opens the connection in raw-bytes mode itself, which it needs in order to read status back, and which wins over an unticked raw bytes box on the device page.
All options are JSON, set on the Network Device page, and all are optional:
{"baudRate": 115200, "codePage": 19, "codePageTable": 16, "paperWidth": 58}
| Option | Default | Description |
|---|---|---|
codePage | 0 | Page number sent in ESC t on connect. |
codePageTable | = codePage | Which of the nine built-in tables to transcode against. Differs from codePage only where the printer's page numbering is non-standard. |
paperWidth | 58 | Paper width in mm, 58 or 80. Drives the read-only facts in device.info. |
baudRate | 115200 | Serial. Overrides both the driver default and the device page. 115200 rather than the 9600 that ESC/POS convention implies, because current printers mostly ship faster. The self-test slip states the rate. |
dataBits and stopBits | 8 and 1 | Serial. 5 to 8 data bits; 1 or 2 stop bits. |
parity | none | Serial. none, even or odd. |
flowControl | none | Serial. none or hardware, which is RTS/CTS and needs those wires. Blocks offers no XON/XOFF. |
usbVendorId and usbProductId | Any adaptor | Serial. Picks one USB-serial adaptor on a Player that has several. Hex as Device Manager shows it, for example 067B and 23A3. |
statusPollSec | 10 | Seconds between status poll cycles. 0 disables polling. |
useAsb | false | Use GS a Automatic Status Back instead of polling. Mutually exclusive with polling. |
replyTimeoutMs | 1500 | How long to wait for a DLE EOT reply before counting a miss. |
chunkBytes | 256 | Maximum bytes per write. Larger payloads are split. |
chunkPauseMs | 20 serial, 0 TCP | Pause between chunks. Images are paced by the printer's own GS r answers as well. |
debug | false | Verbose tracing from construction onward. The only way to trace the connect sequence. |
ffmpegPath | ffmpeg | The ffmpeg that printBlockImage runs, as the Blocks server sees it. |
imageTimeoutMs | 20000 | Longest one image conversion may take before it is skipped. |
imageBandBytes | 2048 | Most bytes in one GS v 0 command. Lower it if large images print as fragments. |
codePage and codePageTable are separate because ESC t n does not reliably select standard table n. Page numbering is vendor-specific and cannot be queried, so it has to be discovered. On the bench printer, page 5 is PC852 Latin-2 and page 16 is Arabic, while the Latin-1 layout lives on pages 19, 21, 24 and 25. So codePage is the number sent and codePageTable picks the table to encode against: 0 PC437, 2 PC850, 3 PC860, 4 PC863, 5 PC865, 16 WPC1252, 17 PC866, 18 PC852, 19 PC858. On a standards-compliant printer the two are equal and only codePage need be set. Use device.diag.scanCodePages() to find the pairing for an unknown model.
Only seven callables sit at the root. Everything else lives in a named aggregate, addressed as aggregate.key.member.
| Path | Holds |
|---|---|
| (root) | The everyday set: print, printLine, printLines, feed, cut, init, sendRaw |
status.paper, status.printer, status.error | Paper, cover and error state read back from the printer, plus recover, poll and testLink |
config.text, config.polling, config.send, config.serial, config.panel | Code page, status polling, send pacing, the resolved serial settings, and the printer's own panel buttons |
printing.style, printing.layout, printing.barcode, printing.graphics, printing.page | Everything that puts marks on paper |
device.info, device.ident, device.control, device.diag | Static facts, GS I identity, cutter and cash drawer, and the bring-up diagnostics |
Binding paths are aggregate.key.member, for example Network.Printer.status.paper.out. The intermediate node alone, Network.Printer.status.paper, is not bindable.
Required arguments, then align, then label, then type-specific tuning.
printLine(text, align?) printLines(text, align?) print1D(type, data, align?, label?, codeSet?) printQR(data, align?, label?, size?, errorLevel?) printPDF417(data, align?, label?, columns?, errorLevel?, moduleWidth?) printBlockImage(block, child, align?, label?, widthDots?, dither?) printImage(widthBytes, heightDots, base64, align?, label?) printNVImage(index, mode, align?, label?)
You cannot skip a middle argument. Blocks truncates a call at the first empty argument, and every later one is silently never passed. The task validator warns "empty value before non-empty ones - the call is truncated at the first empty value". So to reach size you must fill align and label. Pass an empty string for a label you do not want. This is why the ordering above puts the most-wanted arguments first, and why inserting an argument mid-signature silently breaks existing tasks.
align is per call and self-restoring. align is 0 for left, 1 for centre and 2 for right. It applies to that one operation and then restores whatever was set before, so there is no set, print, restore dance. Omit it to keep the current alignment. Everything else, including bold, size, font and barcode geometry, is sticky state set through the settable properties.
label prints a caption on the line below, under the same alignment. For a QR code it is the only way to get text under the symbol, because 2D symbols have no human-readable text in ESC/POS. For a 1D barcode it is a caption in addition to the printer's own HRI text, which encodes the barcode data itself.
Everything on this page reduces to one of these. If a command appears to do nothing, it is almost always the first one.
align argument that handles this for you, and restores the previous value afterwards.
Call these when the buffer is empty: immediately after init(), or after printing.layout.lineFeed(). The driver auto-inserts a line feed before cut() and before every print callable, so print1D, printQR, printPDF417, printImage, printBlockImage and printNVImage are handled for you. Everything else in this table is on you.
| Callable | Bytes | If called mid-line |
|---|---|---|
printing.style.justification | ESC a | Applies to the whole line, including text already buffered, or is ignored entirely. Never set it mid-line. |
printing.layout.setLeftMargin | GS L | Discarded silently. |
printing.layout.setAreaWidth | GS W | Discarded silently. |
printing.style.setUpsideDown | ESC { | Discarded silently. |
cut and device.control.legacyCut | GS V | Handled. The driver emits a line feed first. |
printing.page.select | ESC L | Buffered data is carried into page mode unpredictably. |
printing.graphics.printImage and printBlockImage | GS v 0 | Handled. The driver emits a line feed first. |
The one that catches everyone. printing.style.justification is line-scoped, not cursor-scoped. You cannot left-align one half of a line and right-align the other. For two-column layout use tabs or absolute positions, in section 6.
The first column must be called before the second. These are state, so they persist for every subsequent line until changed or until init() resets them. Set them once at the top of a job, not per line.
| Set this first | Before this | Why |
|---|---|---|
config.text.setPage | print and printLine | Text is transcoded into the page selected at call time. Non-ASCII sent under the wrong page prints as the wrong glyph, or nothing. |
printing.style.font, printing.style.setSize | printing.layout.setTabStops | Tab positions are resolved to dots using the current character width. Set the width first or the stops land in the wrong place. |
printing.layout.setMotionUnits | printing.layout.setAbsolutePosition, setLeftMargin, setAreaWidth | Defines what one dot unit means. Changing it later does not retroactively move anything already set. |
printing.barcode.heightDots, moduleWidth, hriPosition, hriFont | printing.barcode.print1D | All four are read at print time. Setting them afterwards produces a default-sized barcode. |
printing.style.justification | print1D, printQR, printBlockImage | Only when the alignment should persist. For one symbol, pass align to the call instead: it applies to that symbol alone and restores the previous value. |
printing.page.select | printing.page.setArea | The page area command is only meaningful once you are in page mode. |
Three ways to get a left-aligned description and a right-aligned price on one line. They are not equivalent: pick by whether your column positions need to survive a font change.
Most reliable, because it depends on nothing but the character count. Requires knowing the line width: 32 for Font A on 58 mm, 42 for Font B.
// once, at the top of the job
config.text.setPage(19, 16)
printing.style.font = 0 // 32 char lines
// per row, padded in the script
printLine("Kaffe 25.00")
printLine("Räksmörgås 89.00")
printLine("--------------------------------")
printing.style.bold = true
printLine("TOTALT 114.00")
printing.style.bold = false
On paper, 58 mm in Font A:
Kaffe 25.00 Räksmörgås 89.00 -------------------------------- TOTALT 114.00 |-------|-------|-------|-------| 1 8 16 24 32
Positions are character columns, ascending, up to 32 of them. Set the font and character size first, because the stops are frozen to dot positions the moment you set them.
printing.style.font = 0 // FIRST: fixes char width
printing.style.setSize(1, 1) // FIRST: same reason
printing.layout.setTabStops("18,26") // then the stops
print("Kaffe")
printing.layout.tab()
print("1")
printing.layout.tab()
printLine("25.00")
print("Räksmörgås")
printing.layout.tab()
print("1")
printing.layout.tab()
printLine("89.00")
On paper, 58 mm in Font A:
Kaffe 1 25.00
Räksmörgås 1 89.00
|-------|-------|-------|-------|
1 8 16 24 32
^ ^
t=18 t=26
Tab stops are frozen, not dynamic. ESC/POS stores a tab stop as column multiplied by character width, evaluated when setTabStops runs. Set printing.style.widthMul = 2 afterwards and the text doubles but the stops stay put, so the columns drift out of alignment. Re-issue setTabStops after any font or size change.
Positions in dots rather than characters, so they hold regardless of font. Best when mixing sizes on one line. Valid for the current line only, so re-issue it per line.
printing.layout.setMotionUnits(180, 180) // define the unit first
print("Kaffe")
printing.layout.setAbsolutePosition(264)
printLine("25.00")
// mixed sizes still line up
printing.style.setSize(2, 2)
print("SUM")
printing.style.setSize(1, 1)
printing.layout.setAbsolutePosition(264)
printLine("114.00")
On paper, 58 mm and 384 dots:
Kaffe 25.00
SUM 114.00
0 96 192 288 384
|-------|-------|-------|-----|
^
dot 264
The order below is the safe one: reset, then job-wide state, then content, then cut. Justification is only ever changed at a line boundary.
init() // ESC @ -> FS . -> ESC t
// ---- job-wide state, set once ----
config.text.setPage(19, 16)
printing.style.font = 0
printing.style.setLineSpacing(30)
// ---- header, centred ----
printing.style.setSize(2, 2)
printLine("CAFÉ NORD", 1)
printing.style.setSize(1, 1)
printLine("Kungsgatan 12, Malmö", 1)
feed(1)
// ---- body, left ----
printLines("Kaffe 25.00\n" +
"Räksmörgås 89.00")
printLine("--------------------------------")
printing.style.bold = true
printLine("TOTALT 114.00")
printing.style.bold = false
// ---- footer, centred ----
feed(1)
printing.barcode.printQR(
"https://cafenord.se/kvitto/8814", 1, "Kvitto 8814", 5, 1)
printLine("Tack för besöket!", 1)
feed(3) // clear the tear bar
cut(1) // partial; LF auto-inserted
On paper, 58 mm in Font A:
CAFÉ NORD
Kungsgatan 12, Malmö
Kaffe 25.00
Räksmörgås 89.00
--------------------------------
TOTALT 114.00
▓▓▓▓▓▓▓
▓ ▓▓▓ ▓
▓▓▓ ▓▓▓
▓ ▓▓▓ ▓
▓▓▓▓▓▓▓
Kvitto 8814
Tack för besöket!
Feed before cutting. The cutter sits roughly 15 mm above the print head, so the last few lines are still behind it. feed(3) pushes them past. On a printer with no cutter, cut() is silently ignored, which is the hardware rather than a fault.
All four barcode settings are read when the symbol prints, so they go before it. The align argument centres the symbol.
init()
// symbol geometry: before print1D
// geometry is sticky state, so set it once
printing.barcode.heightDots = 80
printing.barcode.moduleWidth = 2
printing.barcode.hriPosition = 2 // text below
printing.barcode.hriFont = 1
printLine("ARTIKEL 4471", 1)
printing.barcode.print1D(
"CODE128", "4471-A", 1, "ARTIKEL 4471")
feed(2)
cut(1)
On paper, 58 mm in Font A:
ARTIKEL 4471
║│║││║│║║│││║║│║│║││║║│
║│║││║│║║│││║║│║│║│││║║
║│║││║│║║│││║║│║│║││║║│
4471-A
CODE128 code sets split by vendor, in opposite directions. Epson's GS k 73 specification requires the data to begin with a {A, {B or {C code-set selector. Many clone controllers do the reverse: they auto-select from plain data and reject the selector outright, printing nothing at all. Verified on hardware, where {B12345 printed blank while 12345 and 4471-ABC printed correctly. So send plain data first, and only if the symbol comes out blank, pass codeSet as B. CODE39 is unaffected and works either way.
QR codes have no human-readable text. GS H, exposed as hriPosition, applies only to 1D barcodes through GS k. The 2D commands, GS ( k, have no HRI function in the specification at all, so no setting will put text under a QR code. Pass a label to printQR instead: it prints the caption as an ordinary line below the symbol, under the same alignment. If you need printer-rendered text beneath the code, use a 1D symbol with hriPosition set to 2.
Print a PNG or JPEG straight from a block's media, with no bitmap preparation and no base64. Name the block and the child that holds the picture. The driver finds the file, converts it to black and white with ffmpeg on the Blocks server, keeps the result, and feeds it to the printer no faster than the printer can print.
// logo: crisp black/white, 256 dots wide, centred
printing.graphics.printBlockImage(
"Main/Receipt", "Logo", 1, "CAFÉ NORD", 256)
// photo: full width (0), dithered greys.
// No caption, but "" is needed to reach
// the later arguments.
printing.graphics.printBlockImage(
"Main/Receipt", "Photo", 1, "", 0, true)
printLine("Tack!", 1) // waits for both images
On paper, 58 mm and 384 dots:
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓ ▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
CAFÉ NORD
░▒▒░░▒▓▓▒▒░░░▒▒▓▓▓▒░░▒▒▓▒░░▒▒░░░
▒░░▒▓▓▓▒▒░░▒▓▓▒▒░░░▒▒▓▓▓▒▒░░░▒▒░
░░▒▒▒▓▓▒░░▒▒▓▓▓▒▒░▒▒▒▓▓▒░░▒▒▒░░▒
Tack!
child is the name the image block has in the editor, and the driver looks up the real file in the block's Spec.pixi. A child with no name can be given by its media file name instead. Get it wrong and the log lists every name and media file the block does have.dither off for logos and line art, where every pixel goes cleanly black or white. Turn it on for photos. Colour is judged by brightness, so a blue and a red can both come out solid black; dithering shows them as different greys.device.diag.clearImageCache() empties the cache.
Black is slow. A thermal head slows right down on solid black. On the bench printer a 5 mm strip took about 2 seconds when solid black and about 1 second when light, so a full-width dark photo takes around 13 seconds, and a black photo background prints as a solid black block. Logos, line art and light images print fastest and look best. The driver paces itself by asking the printer for GS r status after every 2 kB, which is what keeps a large image intact over a serial link with no flow control.
Needs ffmpeg on the Blocks server. PNG and JPEG only, because common ffmpeg builds cannot read SVG. device.info.imageConverter shows the ffmpeg version the server found at startup, or why it found none. The server process may not see the same PATH as an operator's shell, so point the ffmpegPath option at it where needed.
For non-linear layout, such as tickets and labels with rotated text. Strictly ordered: you cannot set the area before entering the mode, and you must leave the mode explicitly.
printing.page.select(), ESC L. The buffer must be empty. Everything after this is composed off-paper.printing.page.setArea(x, y, w, h), ESC W, in dots. Only meaningful inside page mode. Defines the canvas.printing.page.setDirection(n), ESC T. 0 is left-to-right, 1 bottom-to-top, 2 right-to-left, 3 top-to-bottom. Rotation for label stock.printing.layout.setAbsolutePosition for X and printing.page.setAbsoluteVertical for Y. Nothing appears on paper yet.printing.page.printPage() or printAndReturn(). ESC FF stays in page mode for another page; FF prints and drops back to standard mode.printing.page.selectStandard(), ESC S. Always leave explicitly if you used printPage(), or subsequent normal printing goes into the page buffer instead of onto paper.
Escape hatch. printing.page.cancel(), which is CAN, throws away the page buffer without printing. init() also returns to standard mode, at the cost of resetting everything else.
Everything scoped Job is sticky, so set it once per job. init() clears all of it, and the driver calls init() automatically on every connect, so state does not survive a printer power-cycle or a Blocks restart.
| State | Scope | Reset by |
|---|---|---|
| Code page | Job | init(), then re-applied from the codePage option |
| Font, size, bold, underline | Job | init() |
| Tab stops | Job | init(), or re-issuing setTabStops |
| Line spacing | Job | init() or defaultLineSpacing() |
| Barcode geometry | Job | init() |
| Left margin, print width | Job | init() |
| Justification | Line | Persists across lines, but is only changeable at a line start |
| Absolute and relative position | Line | Current line only, so re-issue it every line |
| Double-byte CJK mode | Power-on | init() sends FS . after ESC @, because ESC @ restores the power-on default |
ESC @ is not a clean slate. It resets to the printer's power-on configuration, not to a neutral one. On a printer shipped for the Chinese market that means double-byte mode comes back on, and every byte at or above 0x80 becomes a two-byte lead byte that swallows the character after it. The driver's init() therefore sends ESC @, then FS ., then ESC t, in that order. Never send a bare ESC @ through sendRaw and expect a usable state.
In order. Each step is independent of the ones after it, so a failure localises instead of leaving you guessing across the whole chain.
{"baudRate": 115200} in the driver options. This overrides both the driver default and the device page.printLine("TEST"). An rx ← echo in the log clears the driver, Blocks, the Player, the USB stack and the adaptor in one shot.printLine("HELLO 123") with the printer connected. Nothing on paper means the harness. Check ground continuity first, because separate supplies with no bonded ground are silent both ways and immune to TX/RX swaps, then check that you are on the TTL pins rather than RS-232.feed(3). If the motor turns but nothing prints, the thermal paper is in upside down, because it is only coated on one side.device.diag.printCodePageChart(). One labelled sample line per table. Find the one where the accents read correctly.device.diag.scanCodePages(0, 25), if the chart comes up empty. Sends identical raw bytes across every page number, so it works even where the numbering is non-standard. Widen it to (26, 60) if needed.device.diag.printHighCharTable(). Dumps all 128 high bytes of the current page, 8 per row, to identify an unknown table glyph by glyph.status.error.testLink(). Says within replyTimeoutMs whether the printer's TX is wired back. If it is not, set statusPollSec to 0 and run print-only, because everything else still works.
Tracing the connect sequence. device.diag.setDebug(true) traces every byte once the driver is running, but the connect sequence happens before any callable can be invoked. To see that, put "debug": true in the driver options instead. device.diag.reportState() logs a one-shot snapshot at any time.
It worked yesterday and now prints nothing. If nothing prints and status stops answering, but the self-test slip prints fine, check the wiring before suspecting the driver. On the bench a TX wire had slipped out of its terminal, and it looked exactly like a software regression. status.error.testLink() settles it in a second, because REPLY RECEIVED means the link is good. Note that connected turns green once polling gives up, so green alone does not prove the printer is answering.