← Writing
2026-09-11
8 min

Pretending to be an Epson thermal printer over TCP 9100

How to build a printer emulator that fools a real POS into thinking it just talked to hardware. Notes from the trenches of Epson ESC/POS.

KotlinReverse engineeringPOSESC/POSTCP

If you've never had reason to look at what a POS terminal sends to its receipt printer, here's the summary: it opens a TCP socket to port 9100, dumps a stream of bytes that looks like it's from 1985, and hopes something on the other end understands. That's not a joke. The protocol is called ESC/POS, and 1985 is the year the design pattern was set.

I spent a few months building an Android app that pretends to be that printer. Not to print anything — the goal was to capture, log, and analyze what the POS was actually saying, so we could rebuild the receipt inside our own software instead of pushing paper. The app runs on an old rooted Android tablet sitting in place of the real printer. The POS never knew the difference.

This is a note about the specific things that made it hard, and the specific things that made it work.

The protocol, in one paragraph

ESC/POS is a byte-stream language. Everything is either printable text or a command sequence that starts with a control byte — most commonly 0x1B (ESC) or 0x1D (GS). The command sequence tells the printer to change font, cut the paper, print a barcode, open the cash drawer, feed N lines, or one of a couple hundred other things. Some commands are 2 bytes. Some are 3. Some carry a variable-length payload whose length is either encoded in the command itself or terminated by a newline.

Example, real bytes from a live run:

1B 40                        // Initialize printer
1B 61 01                     // Center justification
1B 45 01                     // Emphasized (bold) on
"THE HUNGRY WOLF\n"          // Header text
1B 45 00                     // Emphasized off
1B 61 00                     // Left justification
"12 x Fries         $18.00\n"
"...\n"
1D 56 00                     // Partial cut with feed

So parsing it is "read a byte, decide if it's a command or text, if command look up what it means, if it takes a payload consume the payload." Sounds easy. It's not.

What makes it hard

Three things. In order of pain:

1. Command length is not self-describing

Some commands are 2 bytes. Some are 3. Some are 4. 1B 4A n prints and feeds n lines — 3 bytes. 1B 74 n selects character code table — 3 bytes. 1D 28 4B pL pH fn m ... sets print mode — variable length, and the length is in the payload.

You can't tokenize this stream without knowing the command dictionary. There's no length byte at a known offset. There's no framing. You have to know that 1B 74 takes one more byte before you can consume the third byte.

The consequence: an unknown command is not just an unknown command. It's a stream desync. If you misjudge the length of one command, every byte after it is misinterpreted. One wrong dictionary entry and the receipt turns into garbage 40 bytes downstream.

2. Every manufacturer implements a different subset

Epson has one set of commands. Star Micronics has another. Bixolon overlaps mostly with Epson but not entirely. Even within Epson, TM-T88V doesn't support what TM-T20III does. The POS software targeting these devices ships with a driver that knows which subset to send. When you emulate the printer, you have to accept anything the POS might throw, including commands from a slightly different model.

Practical impact: your command table starts with the Epson standard reference, then grows a "commands I've actually seen in the wild" tail. The tail is where the bugs live.

3. Cut-through and buffering

TCP is stream-oriented. The POS doesn't send "a receipt" — it sends a stream. Sometimes a single write() on their end is one full receipt. Sometimes it's split across three packets because their driver batches. Sometimes the "print" comes in but the "cut" is 200ms later after the totals are computed.

Your parser has to be resumable. You cannot assume a read() gave you a complete command. You have to accumulate bytes until you can identify a command boundary, and if a half-command is left over, keep it for the next read.

The design that worked

After a couple of iterations, the shape that survived was this:

  1. A command dictionary as a plain map from hex-string to human description. About 60 entries, hand-curated, ordered from most-specific-prefix to least so the longest match wins.
  2. A byte accumulator on the receive side — every read() appends to a rolling buffer, and the parser consumes from the head of the buffer as it identifies commands. Anything unidentifiable at the tail stays for next time.
  3. Structured output, not print output — every command becomes a small JSON object {"command": "Emphasized on", "raw": "0x1B, 0x45, 0x01"}. The buyer of this data is the app upstairs, not paper.
  4. A very forgiving fallback. Unknown byte at the read head? Emit Unknown command with the single byte, advance by 1, keep going. Do NOT crash the stream. In practice this happens ~5% of the time on new POS setups and drops toward zero as I add commands the specific POS uses.

That's it. There is no Trie. There is no FSM in the pure sense. Both of those would be elegant and both would be over-engineered for a 60-entry dictionary. A Map<String, String> and a while (index < buffer.size) loop is the entire hot path.

The Kotlin bits I actually spent time on

Not on the parser. On the socket handling.

ServerSocket(9100) in a coroutine that accepts, then launches a per-client coroutine on Dispatchers.IO. Fine so far. The interesting part is what happens when the POS thinks the connection dropped:

private var serverSocket: ServerSocket? = null
private val port = 9100

fun startServer() {
    CoroutineScope(Dispatchers.IO).launch {
        if (!isPortAvailable(port)) {
            throw TCPServerIsNotRunning("Port is already in use")
        }
        serverSocket = ServerSocket(port)
        while (!serverSocket!!.isClosed) {
            val client = serverSocket!!.accept()
            CoroutineScope(Dispatchers.IO).launch {
                handleClient(client)
            }
        }
    }
}

The port collision. Port 9100 is claimed system-wide on most Android builds by the actual print spooler. On an unrooted tablet you cannot bind it. That's why the app assumes root — I had to pm disable the built-in print service just to bind the port. The alternative would be a lower port and DNS trickery, but every real POS driver hardcodes 9100.

Connection reuse. A well-behaved POS opens one long-lived socket and streams every receipt through it. A poorly-behaved one opens a fresh socket per receipt. Both are legal. If your accept loop or per-client coroutine leaks anything (a hanging output stream, an un-cancelled buffer flush) you'll hit "too many open files" in a shift.

ASB — the Automatic Status Back message. Real Epson printers can be configured to push status updates to the host: out-of-paper, cover-open, error. If you don't send these, the POS eventually decides you're a broken printer and stops sending. So the emulator has to lie occasionally: "hi, still here, paper okay, no errors." That code lives outside the parser and is the one place where I do actually run a small state machine — tracking the ASB-enabled state, the ASB mask, and the last process ID acked. That's where the FSM lives, if anywhere.

What I got wrong the first time

Two things worth naming.

I treated it like a text protocol. The very first version accumulated everything as a String, split on newlines, and tried to identify commands as text markers. This works for maybe 30% of ESC/POS traffic (the printable stuff) and falls apart the moment a barcode payload contains a 0x0A byte that isn't a line feed. Rewriting the buffer as ByteArray was a one-day rewrite. It made the parser real.

I built the command table alphabetically. Because when you look at the Epson reference PDF, the commands are grouped by function. So I typed them in that order. The result was a table where the longest-prefix match required scanning most of the table. Reordering by specificity (longer sequences first, then shorter ones) made the parser 8x faster on the hot path and, more importantly, killed a class of bugs where 1B 40 was being matched before 1B 40 nn.

What I'd tell someone starting this now

  • The reference PDFs lie by omission. The Epson doc lists what the printer supports. The POS software may send commands the printer ignores — command bytes that mean nothing to the printer but that the POS driver emits anyway. You have to accept these silently. In your emulator, an unknown command is not an error, it's data.
  • Log the raw bytes forever. Every session should dump the full byte stream to disk before parsing. When something breaks in the wild you'll be very glad to have the raw stream to replay.
  • Assume the POS will reconnect. Session state doesn't survive across sockets. Anything you're accumulating (partial receipt buffer, ASB flags) either survives independently or gets thrown away on disconnect. Both are fine — just decide which.
  • Get the byte-level unit tests in early. A regression here is silent — receipts still look plausible, they're just wrong. Only automated tests on real captured byte streams catch it.

Truffle Printer Emulator case study: thesyedabbas.xyz/projects/truffle-pos-integration