matt's site !!

Fun with the Epson TM-88V thermal receipt printer

Hello! I am now the proud owner of an Epson TM-T88V thermal receipt printer from a seller in fairly good condition (minus a few cosmetic defects, we’ll get to that).

I’ve told a lot of my friends and colleagues that I bought a receipt printer, and the universal reaction has been, “Why?!”. But, to answer that question, we’ll need to take a brief detour.

History

It was mid-2023, and I was working on a group project for my undergraduate degree at the University of Queensland. Specifically, I preparing with my team to start on our DECO3801 capstone project. This would later turn out to become Hermes/Atlas, but it wasn’t always going to be that way. We actually applied for a project that involved “biosecurity awareness”, essentially, a project around educating the public about the risks of pervasive biotechnology surveillance. We didn’t end up getting this preference, and instead got a different one around preparing for the Brisbane 2032 Olympics, which we developed into Hermes/Atlas. Strangely enough, however, this is where the receipt printer concept originates from. Let me explain.

Unfortunately, I’m unable to share the specifics of the biosecurity awareness project, as the document is under a licence that explicitly forbids reproduction outside the university. However, I can explain the pitch that we came up with.

Long story short, the game was to be set in a cyberpunk dystopia in the year 2086 modelled off the US. After a surprise victory, the president of this country has tasked you, an employee of the Rice-Steele TechnoUnion, with using pervasive biosensors to defeat the “Night Force” alleged terrorist group.

The game was going to consist of both a PC game, as well as an external peripheral called “The Box”. The Box would have consisted of a bunch of tactile, old equipment like dials, knobs, character LCDs, a fingerprint reader… and yes, a receipt printer! This was going to be how you would interact with the game: when you were issued a new task by the TechnoUnion to install a new sensor, or surveil a street, this would be printed off as a physical receipt. Basically, think Papers: Please and Orwell crossed over, but with a focus on surveillance, agent-based modelling (for the civilian/terrorist game AI) and dynamic storytelling.

Although this game never shipped (we didn’t get assigned the project), I’m still interested in prototyping something like it in the future if time permits. I think the combination of the virtual world plus the physical interaction offered by “The Box”, including the receipt printer, would make for a very interesting experience.

Anyway, apologies for the tangent, but that basically explains the origins of the receipt printer idea!

Uses for a receipt printer

So, what are we actually going to use this thing for? My current plan is basically to connect it up to systemd, such that when background jobs run on my system (for example, backups, btrfs-maintenance, etc.) we can print off a receipt. A similar thing could be done with email - that’s typically how cron jobs notify you when they complete - but receipts are way more fun! I’d also like to tie this into dbus, and print a receipt whenever I receive a Discord notification. This is just a bit so that when people say something dumb, I can go “I have the receipts” (literally).

Purchasing and unboxing

Receipt printers are expensive! Damn! I trawled eBay for quite a while until I found a suitably priced one, which was a second (third?) hand Epson TM-88V thermal receipt printer, for around $80 AUD including shipping. I chose this specific model because the seller had tested it, and it came with all the necessary cables and power adapters, so it seemed like a good deal. There were cheaper, brand-new mini Chinese thermal printers, but they just didn’t have quite the same vibe. They feel more like crapware than something rugged and second-hand.

Anyway, some time later, my receipt printer arrived in the mail. Unboxing it was fun, the printer came with a bunch of stickers already attached to it, presumably from its previous owner(s).

The dinged-up box the printer came in (it was safe though!)
The dinged-up box the printer came in (it was safe though!)

The Epson TM-88V, in all its glory
The Epson TM-88V, in all its glory

I peeled off the attached stickers, and then cleaned up the sticky residue with some Orange Powerâ„¢, which is basically concentrated orange juice.

Not a shill.
Not a shill.

Here’s a better look at the attached stickers, plus a piece of an actual receipt I found in the cutting mechanism while cleaning it.

The first thing is that cut-off receipt I found in the cutting mechanism, which appears to be from Ted Baker, a London-based boutique fashion brand that closed up shop in 2024 (this would make sense then as to why the receipt printer is on eBay!)

The second note was stuck to the printer. For my readers who aren’t Australian, CBA refers to the Commonwealth Bank of Australia (although nowadays we usually call it CommBank). The 1800 230 177 number indeed points to CommBank’s merchant services hotline still. The “merchant number” and “terminal ID” are probably useful to find the origins of this printer, but I wasn’t able to find much myself without access to CommBank’s database.

Finally, the very faded handwritten “CBA Authorisation” note again refers to CommBank, and just has the phone number for CommBank (13 26 36).

After I plugged in the receipt printer, I was able to print a test document by holding down the “Feed” button and powering on the printer at the same time.

Incredibly, this shows that the thermal head has travelled 18 km, and that the cutter has cut over 78,000 receipts! This thing clearly has had quite a life before I picked it up, wow.

Installing the Epson TM-88V driver

There are a few options online for printer drivers for the Epson TM-88V. All options interface with CUPS, the standard printing subsystem for Unix.

The first option was this GitHub repo. I followed the instructions to build and install this; but it did not work. Either I misconfigured the driver, or didn’t install it correctly, but basically what would happen is the printer would really quickly print about a millimetre worth of blank paper and nothing else.

Failing that, I got the official drivers from Epson’s website, which you can also get here. Epson’s website is actual ass, and often doesn’t work, so you can also use my mirror here (4 MB .zip).

In any case, now we have a problem - this “driver” consists of binary DEBs compiled for Ubuntu 9.10 (!!). This system is 15 years old, so old in fact that it’s not even possible to (easily) run in a Docker, which only goes back as far as about 16.04 officially. Instead, I turned to debtap, a really cool tool that will attempt to turn DEBs into Arch Linux packages. I ran that on the DEB files to generate Arch Linux packages. Almost all of these failed to install due to various problems with the ancient install scripts calling out to stuff like init.d (this is pre-systemd, remember!)

Anyway, I decided to press on under the naive hope that it would maybe just work. I visited the CUPS UI, which you can access at http://localhost:631/ (login with your system credentials). You can then add a printer, and the Epson TM-88V will show up:

However, this doesn’t seem to be configured correctly, as going ahead and selecting the Epson printer will request a URL, which is nonsensical for a USB connected printer. None of the URLs (various variations of localhost) I tried worked, so I think this is a bust.

Incorrect setup page for the printer
Incorrect setup page for the printer

However, as a Hail Mary, I decided to select “Unknown” (you can see I have it selected in the first image). This will then show that “Unknown” is in fact the Epson printer, connected via USB!

If you hit continue, you’ll then be prompted to select the printer type/driver. What I did here, instead, was to upload and pass the PPD file to CUPS, which was mentioned in the Epson docs. I’m really not at all knowledgeable about Linux printing infrastructure (or printing in general), but it appears that the TM-88V is actually a PostScript printer 1, and a PPD file describes the capabilities and layout of the printer.

Note that this PPD file is contained as part of the Epson driver linked above, in the .tar.gz, and then in the tmt-cups/ppd directory. If you want to just download it directly, I’ve mirrored it here.

I’m not sure if this works half because the printer driver was installed, and half because the PPD file was uploaded? Or if we could skip the dodgy driver setup and just use the PPD file on a vanilla CUPS? Either way, good news, it works!!

Printing receipts

Now that we’ve confirmed the printer works, we need a way to automatically fill in and print receipts for the systemd tasks that complete.

For this, I whipped up a bunch of Python scripts called receiptd (ISC licence). Essentially, the idea is to use the wonderful Typst to generate receipts from templates, save them to /tmp, and then use lpr(1) to dispatch them to the printer. I think it’s really cool that lpr will ingest PDFs directly, without me having to convert them to PostScript or anything like that.

The code is available on GitHub (above), but let me walk you through the key parts. Here’s the receipt template, in Typst, for the “background task” receipt type. You can see it takes a bunch of args via sys.inputs, which we can pass from Python using typst ... --input arg=contents.

 1// Font size
 2#set text(size: 14pt, font: "Hack Nerd Font")
 3#show raw: set text(size: 8pt)
 4// Display
 5#set page(
 6    width: 80mm,
 7    height: 297mm,
 8    margin: (
 9        left: 0mm,
10        right: 0mm,
11        top: 0mm,
12        bottom: 0mm
13    )
14)
15#set par(justify: true)
16
17#align(center, [
18= BACKGROUND JOB \ COMPLETED
19])
20
21#image("separator.svg")
22
23#set text(size: 12pt)
24*JOB: #sys.inputs.name*
25
26*TIME: #sys.inputs.time*
27
28*STATUS: #sys.inputs.status*
29
30// *LOG:*
31//
32// #raw(sys.inputs.log)
33
34#v(2em)
35
36#set text(size: 10pt)
37*SERVED BY:*
38
39*MAINFRAME "#sys.inputs.hostname" ON _#sys.inputs.hostname\.LOCAL_*
40
41(C) #datetime.today().year() DATA CONTROL CORPORATION
42
43
44#align(center, [
45    #image("norecycle.svg", width: 15%)
46
47    _CANNOT BE RECYCLED_
48])

In the above document, I set the margins to zero, because the thermal printer seems to add its own. I set the width/height of the page to the size of a receipt (80mm x 297mm).

I write everything in all caps, and include references to “Mainframes” and “Data Control Corporation”2; I like the 1960s/1970s mainframe aesthetic, it’s always fascinated me, and I wanted to replicate it here. I took inspiration from the U.S. Graphics Corporation website, which is very up that alley.

Due to their chemical composition, thermal receipts actually cannot be recycled, at least in Australia! I don’t know how many people know this, so I included a “no recycle” image, which is an SVG I combined from OpenClipart artwork (CC0 licenced).

I’m using the Hack Nerd Font, which is a monospace font, it looks quite nice. I also was originally going to include an output log, and would still like to, but haven’t plumbed it in yet, so it it’s just commented out at the moment.

With the template written, the follow Python in print.py is actually responsible for printing the receipt:

 1# Name of the receipt printer to use
 2PRINTER = "EPSON"
 3
 4
 5def print_job_receipt(name: str, status: str, log: str, hostname: Optional[str] = None):
 6    if status not in ["ok", "fail"]:
 7        raise RuntimeError(f"Invalid status: {status}. Acceptable: 'ok', 'fail'")
 8
 9    if hostname is None:
10        hostname = socket.gethostname().upper()
11    else:
12        # ensure uppercase
13        hostname = hostname.upper()
14
15    display_status = "NOMINAL" if status == "ok" else "FAILURE"
16    time = datetime.now().strftime("%d/%m/%Y %I:%M %p").upper()
17
18    with tempfile.NamedTemporaryFile("wb", delete=False, prefix="receiptd_", suffix=".pdf") as f:
19        command = (f"typst compile --input name=\"{name.upper()}\" --input "
20                   f"status=\"{display_status}\" --input hostname=\"{hostname}\" --input time=\"{time}\" "
21                   f"--input log=\"{log.upper()}\" receipt_job.typ {f.name}")
22
23        # compile with typst
24        subprocess.check_call(shlex.split(command))
25
26        # now print it!
27        subprocess.check_call(["lpr", "-P", PRINTER, str(f.name)])

This is pretty simple, we’re just capitalising everything, formatting the time 3, writing it to a PDF, and calling lpr.

With all of that, here’s an example of a real receipt that was printed off:

This was generated for a background systemd timer job that syncs my Dropbox folder to a local Immich install (I’ll probably blog about this later).

Here’s how it looks when physically printed:

Networked printing

Since some jobs run on computers other than my main machine, serpent, I also want to be able to print remotely from these machines over the network.

Thinking about it now, it would have been smarter to use CUPS to advertise the printer over the network… buuutttt this was not the way I ended up doing it. Instead, I implemented a small Flask web-app that implements a simple printing REST API. Here’s the code:

 1from flask import Flask, request
 2from print import print_job_receipt
 3
 4app = Flask(__name__)
 5
 6
 7@app.route("/print/job", methods=["GET"])
 8def print():
 9    name = request.args["name"]
10    status = request.args["status"]
11    hostname = request.args["hostname"]
12    log = "none"
13
14    try:
15        print_job_receipt(name, status, log, hostname)
16        return "OK"
17    except Exception as e:
18        print(f"Failed to print: {e}")

You can then start this using Gunicorn, as follows:

1gunicorn -w 4 -b 0.0.0.0:4020 'print_server:app'

Then, you can call it like this from the target machine:

1curl -v 'http://serpent:4020/print/job?name=TESTINGstatus=ok&hostname=lagoon'

Connecting to dbus

The next project is connecting the receipt printer to the dbus server, so that we can listen in on notifications and print a receipt when we get one. This is mainly for Discord, but it should work for all notifications we receive on this system. I based this on this GitHub Gist. The code that does looks a follows, and basically listens to the dbus daemon and then calls into the above print() routine:

 1
 2#!/usr/bin/env python3
 3# Based on: https://gist.github.com/eacousineau/63651e498ddc31a7c1478f8638d0fd2e
 4from collections import namedtuple
 5
 6from gi.repository import GLib
 7import dbus
 8from dbus.mainloop.glib import DBusGMainLoop
 9
10from print import print_notification_receipt
11
12# Similary to `dnotify` package.
13BUS = "org.freedesktop.Notifications"
14OBJECT = "/org/freedesktop/Notifications"
15IFACE = "org.freedesktop.Notifications"
16
17# For the "Notify" event (see references above).
18KEYS = (
19    "app_name",
20    "replaces_id",
21    "app_icon",
22    "summary",
23    "body",
24    "actions",
25    "hints",
26    "expire_timeout",
27)
28
29CallKey = namedtuple("CallKey", ("sender", "dest", "serial"))
30
31
32def notification(summary, body):
33    print_notification_receipt(title=summary, message=body)
34
35
36def main():
37    def on_call(message):
38        kwargs = dict(zip(KEYS, message.get_args_list()))
39        summary = str(kwargs["summary"])
40        body = str(kwargs["body"])
41        notification(summary, body)
42
43    def on_any(bus, message):
44        if (message.get_interface() == IFACE
45                and message.get_member() == "Notify"):
46            on_call(message)
47
48    dbus_loop = DBusGMainLoop()
49    bus = dbus.SessionBus(mainloop=dbus_loop)
50    bus.add_match_string_non_blocking("eavesdrop=true")
51    bus.add_message_filter(on_any)
52
53    main_loop = GLib.MainLoop()
54    print("Now listening for notifications")
55    main_loop.run()
56
57
58if __name__ == "__main__":
59    main()

Now, with this, we will get receipts as follows!

(Note that I blurred the name as it’s the handle of one of my friends)

To show that it applies to other notifications as well, I received this after KWin crashed due to an AMD GPU driver bug. This was really cool, because I otherwise would have had no way of seeing this!

Conclusion

I think that this receipt printer has been a lot of fun, and I’m still using it in my day-to-day setup. Now that I have it, I might have a go at developing the game I wrote about earlier when time permits. Finally, I did also note that the test printout mentioned a “firmware version” - if I do decide to decommission this printer, I’ll see if I can dump and reverse engineer an image of the firmware. Perhaps see what type of CPU it uses inside as well, that would be fun.

Thanks for reading, and see you next time!


  1. At least, either that or someone somewhere in the printing chain is accepting PostScript. ↩︎

  2. This is a not so subtle reference to Control Data Corporation, a pioneering mainframe company from that time period; but hopefully I won’t attract lawyers this way. ↩︎

  3. Extremely annoyingly, Typst refuses to support a #datetime.now equivalent (and then locked the thread asking for it), so we have to pass the time in from Python manually. ↩︎

#multimedia #software