Thermal Printer Emulator
Android app that impersonates an Epson TM-m30II thermal printer at the hardware, network, and protocol level. Intercepts print jobs from Toast, Square, Clover, parses ESC/POS binary commands via a Trie, extracts structured order data, and forwards it as JSON. No POS system modifications required.
Physical printer hardware is a bottleneck for POS integrations: expensive, scarce for testing, and vendor-locked. A printer that isn't a printer, but talks the language perfectly, unlocks the receipt data closed POS systems refuse to expose. The app runs on a rooted Android device on the same subnet as the POS terminal and looks exactly like a real Epson to the POS.
For a POS system to talk to us, we need to look, respond, and identify like a real Epson. That means four things in parallel. A UDP discovery server on port 3289 (UdpServer.kt:20) answers Epson's "EPSONq" broadcast queries with 8 distinct response handlers (prepareUnicastResponse at :206, sendFirstDetailedResponse at :266, sendSecondDetailedResponse at :296, Third-response versions A/B at :322/:350, sendFourthDetailedResponse at :379, and Fifth/Sixth at :432/:458) covering the handshake variants POS systems try during discovery. A TCP printing server on port 9100 accepts persistent connections and streams print jobs (TCPServer.kt:20). A Ktor HTTP/SOAP server emulates the Epson SDK's CGI endpoints so web-based POS systems that talk to "the printer" over HTTP get an authentic XML success response. And an active broadcaster (BroadcastService.kt) pushes the EPSONq response plus two "detailed responses" to every IP in 192.168.1.1..250 (loop at line 46, with an explicit skip at 49 for the emulator's own address), every 1500ms (default intervalMs at :27), so POS systems on the network never lose sight of the printer. Underneath all four, MAC-address spoofing takes the last three bytes of the device's actual MAC and prepends Epson's OUI (DashboardFragment.kt:230 constructs "38:1a:52:" + deviceMac.split(":").subList(3, 6).joinToString(":")), then applies it with a DHCP renewal so POS systems that validate manufacturer identity see a real Epson.
ESC/POS isn't a text protocol. It's a binary stream where a print job is a mix of ASCII text, escape sequences, image bit-blits, and status queries. Some commands are 2 bytes; some are ESC + n bytes where n is a literal parameter; some are variable length with the length encoded as (nL + nH * 256) in the following two bytes; some are NUL-terminated like barcodes. ECSTransformer.kt (1,085 lines) parses this with a Trie of command byte-sequences (CommandTrieNode class at :45, with `children: MutableMap<Byte, CommandTrieNode>` at :48, insertion at :85 via `children.computeIfAbsent(byte)`, traversal at :858) for O(k) lookup — where k is the command length, not the size of the command table. When the Trie matches, a per-command parser handler runs: fixed-length ones extract N bytes directly, variable-length ones use exp4j (imported at :2 as `net.objecthunter.exp4j.ExpressionBuilder`, evaluated at :985) to evaluate the length expression against the parameter bytes, and image commands (ESC *, GS v 0) calculate their skip byte-count from width×height parameters and drop the raster payload without allocating for it. 50+ commands across 7 categories: text formatting, alignment, paper control, character sets, barcodes/images, status/ASB, and initialization.
Once the bytes are text, they still don't look like structured data. "2 Burger $5.99" and "Burger x2" and "Burger Qty: 2" are three different POS vendors saying the same thing. ReceiptHandler (services/ReceiptHandler.kt 227 LoC + ui/dashboard/mapping/ReceiptHandler.kt 793 LoC ≈ 1,020 total) loads JSON templates from app/src/main/assets/Templates/ that declare per-vendor receipt structure, then applies a line classifier (ITEM · MODIFIER · KEY_VALUE · OTHER) with 10+ format regexes. Modifier lines attach to the preceding item via a state machine so "(No Onions)" ends up nested under "Burger" rather than as a sibling line. Field extraction uses a synonym system with three fallback strategies: colon key-value, right-aligned multi-space value, then full-line last resort. Kotlin reflection populates the ReceiptData class dynamically — mapping/ReceiptHandler.kt:318-319 filters `ReceiptData::class.memberProperties.filterIsInstance<KMutableProperty1<ReceiptData, String?>>()` and writes matched fields by name (also at :670-671, :724, :748 for menu items). Adding a new template field means editing JSON and adding a property to ReceiptData, not writing new parser code.
Two things need root and there's no way around either. Binding to port 9100 requires privileged port access on Android (any port below 1024, and Epson's discovery on 3289 through the udp system needs subnet broadcast permission that non-root apps get sandboxed out of). And MAC address spoofing changes the network interface identity, which is a system-level operation. The app minimizes what root touches: MAC change on startup only, port bind on service start, no persistent root shell. Rooting requirement was disclosed upfront to Truffle so they could budget it into the deployment story (Magisk on the target devices).
POS terminals hold connections open — they don't reconnect per receipt. TCPServer.kt configures TCP_NODELAY at :202 (`client.tcpNoDelay = true` with comment "Disable Nagle's Algorithm") so status responses ship without waiting to fill a segment, and 64KB (65536 byte) SO_SNDBUF / SO_RCVBUF at :203-204 so a large receipt fits in one buffer without packet fragmentation. Each incoming connection runs in its own coroutine scope for isolation — a runaway job on one terminal can't stall the others. The status responder handles DLE DC4 real-time queries during a print (needed for paper-out / cover-open state that POS systems poll for), so mid-print status queries return without breaking the ongoing receipt buffer.
A real Epson TM-m30II is $200–$500 and one per POS terminal per dev seat. A single Android device covers 10+ POS terminals in testing. More importantly, it unlocks the data — closed POS systems that don't expose an integration API still have to send bytes to a printer, and now those bytes are queryable JSON. The output flows out through Android Intents for on-device consumers or over Retrofit/OkHttp for downstream backends.