ESC/POS Printer Driver for PIXILAB Blocks

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.

Source files

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.

1. Device setup

For a printer on a Player's serial port, create a Serial Network Device:

  • Address: The Display Spot providing the port
  • Line settings: Through the driver options below, which override the device page

For a network printer, create a TCP Network Device:

  • Address: Printer IP address
  • Port: 9100

The 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.

Driver options

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.

2. Paths and argument order

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.

3. Three ways order matters

Everything on this page reduces to one of these. If a command appears to do nothing, it is almost always the first one.

  • Line-start only. Margins, print width, justification, cut and page mode take effect only when the print buffer is empty. Called mid-line they are discarded with no error and no log entry. Every print callable takes an optional align argument that handles this for you, and restores the previous value afterwards.
  • Set before use. Barcode height, width and HRI position must be set before the barcode is printed. The same goes for the code page before text, and motion units before any dot measurement.
  • Resolved at set time. Tab stops are converted to absolute positions using the character width in effect when you set them. Changing font afterwards does not move them.

4. Line-start only

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.

5. Set before use

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.

6. Columns and tabs

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.

A. Space padding, computed in the script

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

B. Tab stops, setTabStops and tab()

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.

C. Absolute dot positions, setAbsolutePosition

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

7. Full receipt sequence

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.

8. Barcode and label sequence

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.

9. Images from a block

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!
  • The child's name, not the file name. 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.
  • Threshold or dither. Leave 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.
  • Replacing the picture just works. Conversions are cached, so a repeat print starts at once. Replace the image in the editor and the next print converts it again, with no cache-busting needed. device.diag.clearImageCache() empties the cache.
  • Order is kept. The call returns at once and converts in the background, but anything printed after it waits for the image, so the paper follows the Task's order. A missing or broken image prints nothing and logs why, and the rest of the job continues.

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.

10. Page mode

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.

  1. printing.page.select(), ESC L. The buffer must be empty. Everything after this is composed off-paper.
  2. printing.page.setArea(x, y, w, h), ESC W, in dots. Only meaningful inside page mode. Defines the canvas.
  3. 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.
  4. Position and print. Use printing.layout.setAbsolutePosition for X and printing.page.setAbsoluteVertical for Y. Nothing appears on paper yet.
  5. printing.page.printPage() or printAndReturn(). ESC FF stays in page mode for another page; FF prints and drops back to standard mode.
  6. 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.

11. What survives, what resets

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.

12. Bring-up on unknown hardware

In order. Each step is independent of the ones after it, so a failure localises instead of leaving you guessing across the whole chain.

  1. Printer self-test, holding FEED while powering on. Needs no serial link at all. Proves power, head and paper orientation, and prints the baud rate and character mode. Always do this first.
  2. Set the baud rate from that slip, with {"baudRate": 115200} in the driver options. This overrides both the driver default and the device page.
  3. Loopback the adaptor. Short its own TX to RX with the printer disconnected, then printLine("TEST"). An rx ← echo in the log clears the driver, Blocks, the Player, the USB stack and the adaptor in one shot.
  4. 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.
  5. feed(3). If the motor turns but nothing prints, the thermal paper is in upside down, because it is only coated on one side.
  6. device.diag.printCodePageChart(). One labelled sample line per table. Find the one where the accents read correctly.
  7. 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.
  8. device.diag.printHighCharTable(). Dumps all 128 high bytes of the current page, 8 per row, to identify an unknown table glyph by glyph.
  9. 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.