====== 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. ==== Print callable argument order ==== 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. - **''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. - **Position and print.** Use ''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. ===== 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. - **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. - **Set the baud rate from that slip**, with ''{"baudRate": 115200}'' in the driver options. This overrides both the driver default and the device page. - **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. - **''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.