Adafruit mini thermal printer, part 2/?: CUPS and other vessels

I bought a printer and have blogged about it because it’s literally the most interesting thing ever.

Code on github: https://github.com/edrosten/adafruit-thermal-printer-driver. Note: I wrote these posts as I went along so there may be bugs in the code snippets which are fixed later. I recommend checking the GitHub source before using a snippet.

This post is about integrating with CUPS so I can print from normal programs.

Integrating with CUPS

So, I have a sort of working example of CUPS integration in the existing ZJ-58 driver. It is, I suspect not very good. Nonetheless, I’ll start there since it’s vastly easier starting from a working example than the documentation.

Note from the future: The documentation…

It does exist, but it’s scattered over the various projects, those being PostScript, other Adobe printer guff, CUPS, the GhostScript interpreter, the printer working group and so on). It’s the type of documentation where you can’t find anything so you do 95% of the work the hard way, get stuck on an obscure API call/keyword/etc and then that string turns up the documentation you needed at the beginning.

Anyway, Here’s the install script from the existing driver:

#!/bin/bash

# Installs zj-58 driver
# Tested as working under Ubuntu 14.04

/etc/init.d/cups stop
cp rastertozj /usr/lib/cups/filter/
mkdir -p /usr/share/cups/model/zjiang
cp ZJ-58.ppd /usr/share/cups/model/zjiang/
cd /usr/lib/cups/filter
chmod 755 rastertozj
chown root:root rastertozj
cd -
/etc/init.d/cups start

That’s pretty simple: basically it dumps some files into the CUPS tree and restarts CUPS.

From what I understand, CUPS essentially has some sort of specification of various filter chains (which can vary based on the input, e.g. a plain text file, a postscript file and a JPG will have different input filters). A given filter lists its accepted inputs and CUPS works backwards to figure out how to generate what’s required. For raster things (i.e. not plain text when the printer can accept plain text) CUPS will rasterise the input and you need to then get it sent to the converter to convert it to the right control codes (mostly the topic of the previous post).

Many of those are controlled by a PPD file. This stands for “PostScript Printer Description” and tells CUPS all about the printer capabilities. It also has extensions beyond the Adobe PPD spec to allow you to specify rasterisation and filters for non PostScript printers.

The driver of course comes with a PPD (it has to), but it’s long, complicated, has fragments of PostScript in it and doesn’t even pass the tests run by the cupstestppd command. And there’s a lot of duplicated information about pages sizes which I suspect needs to be consistent. Not great but it’s a start.

So, while PPD is documented (or some approximation thereof) and the CUPS extensions are likewise, apparently you’re not really meant to write PPDs anyway. The easy/approved way is to write DRV files and then compile them into one or more PPD files using ppdc.

Either way the documentation is poor. There are lots of attributes in existing PPD and DRV files like “Filesystem” that it’s very hard to find any kind of documentation for and others like “PSVersion” which are weakly documented (what are the acceptable range of values?).

Some information is here. On the subject of PSVersion, typing revision = into a GhostScript interpreter reveals that my machine (Ubuntu 18.04) has revision 926 for whatever that’s worth. Either way it seems optional. Anyway, I’ve tried to pare down my DRV file to the absolute minimum which covers what I want and I got this:

#include <font.defs>

DriverType custom  //Required I believe to set downstream filters 
ManualCopies Yes //Set to yes if the driver doesn't know how to print multiples of pages
Attribute "LanguageLevel" "" "3" //Default is 2 (from 1991), latest version is from 1997
Attribute "DefaultColorSpace" "" "Gray" //Self explanatory except does this mean something else can change it?
Attribute "TTRasterizer" "" "Type42" //Default is none, Type42 is the only extant useful one.
Filter application/vnd.cups-raster 0 rastertoadafruitmini //Arguments are datatype to feed to the filter, the expected CPU load, and the name of the filter executable
ColorDevice False

Font * //Include all fonts

// Manufacturer, model name, and version of the driver
Manufacturer "Adafruit"
ModelName "Mini"
Version 1.0
ModelNumber 579 //That's the product number on the website.

//I believe this allows users to specify custom sizes in the
//print dialog, or on the command line.
VariablePaperSize Yes
MinSize 58mm 5mm
MaxSize 58mm 1000mm

//#media creates media definitions which may or may not be used
//The paper is always 58mm wide, and have for now three different
//lengths
#media "58x50mm" 58mm 50mm
#media "58x100mm" 58mm 100mm
#media "58x200mm" 58mm 200mm

//The print area is always 48mm wide, centred
HWMargins 5mm 0 5mm 0

//This actually uses the media definitions above
*MediaSize "58x50mm"
MediaSize "58x100mm"
MediaSize "58x200mm"

// Supported resolutions
// Use as: Resolution colorspace bits-per-color row-count row-feed row-step name
// Apparently mostly the row stuff is 0 in most drivers. The last field
// (name) needs to be formatted correctly
*Resolution k 8 0 0 0 "208dpi/208 DPI"

// Name of the PPD file to be generated
PCFileName "mini.ppd"

OK, strictly speaking this isn’t the absolute minimum, since I’ve specified several virtual page sizes and variable sized pages, which is how CUPS deals with roll media. Here’s the corresponding install shell script to dump things in the right place:

/etc/init.d/cups stop

mkdir -p /usr/share/cups/model/adafruit
install rastertoadafruitmini  /usr/lib/cups/filter/rastertoadafruitmini
install ppd/mini.ppd /usr/share/cups/model/adafruit/mini.ppd

/etc/init.d/cups start

Now, running that and going to http://localhost:631 and going through the motions shows the printer there with the options I’d expect (i.e. paper size). The printer device appears as “unknown” in CUPS since it works as a USB parallel port (/dev/usb/lp0), but doesn’t report anything back to CUPS. Even with that , it won’t work yet, because I need in no particular order

  • Proper information logging to stderr in a format that CUPS likes
  • Deal with commandline arguments that CUPS hands me
  • Handle SIGTERM (used to cancel jobs) and not leave the printer in a bad state

In addition, you can add arbitrary choices to the driver which get passed on to the filter so I think I’ll add ones for feeding paper after the job has done (so the end of the last page ends at tearoff on the printer), auto cropping pages (removing white space at the top and bottom–useful for roll media), and marking page boundaries. Because why not? I only have to implement them later.

Options are implemented using an option directive followed by a bunch of choice directives, e.g.

Option "TestOption" PickOne DocumentSetup 0
  *Choice "A" ""
  Choice "B" ""
  Choice "C" ""

You can have Boolean, PickOne or PickMany. I don’t really see the point of Boolean: all of them need to have choice directives (for reasons which will soon become clear), so there’s little difference between a Boolean and a PickOne with two options.

The only difference seems to be that it renders a boolean as a radio group not a drop down list in the web interface:

hmmm. I wonder…

OK Confirmed! You can have as many “boolean” choices as you like, though note that the troolean choices don’t appear in the print dialog boxes, whereas booleans appear as checkboxes. Neither the compiler nor the validator complained which seems like a mild oversight.

With that silly aside out of the way, the next bit is how those options are passed to the printer driver. It turns out there are two ways, both of which are applied simultaneously.

The first, is that the options are passed as a command line argument to the filter, along with the PPD file (in the PPD environment variable). The CUPS API provides some handy functions for parsing PPD files and option strings and generally dealing with it.

The second is that each choice comes with an arbitrary snippet if PostScript code which is run at the point specified by the option directive (it can be at places like document start, page start). Now PostScript has a setpagedevice command which basically accumulates a dictionary for device specific use. The CUPS driver will put certain elements in that dictionary into the raster page headers, and you can access them from C in the filter. It doesn’t support arbitrary dictionaries, and in fact what it has is:

unsigned cupsInteger[16];
float cupsReal[16];
char cupsString[16][64];

You can fill these up by putting appropriately named things into the dictionary, e.g.:

<</cupsInteger1 10 /cupsReal7 2.2 /cupsString3 (a string)>> setpagedevice

W00t! I just found the documentation (by searching for cupsInteger0 to see if it was 0-based or 1-based; it’s 0-based). Turns out there are loads of parameters you can pass this way. Many have “accepted” meanings but you can abuse them to pass arbitrary data since you control both sides.

The two choices are pretty much equivalent, so I’ll pick… uh. Ummm OK wow I’m suffering from choice indecision here. OK, I’ll go for option 2. The API for option 1 is the usual annoying C faff, plus apparently it’s been deprecated since 2012 and I don’t have a nice example of the new API to copy from.

Putting all that together code added to the DRV file looks like this:

//The last argument is the order in which the order in which the options 
//are executed (each one comes with a snippet of code to execute). In this
//case, all snippets are empty.
Option "PageFeed/Feed paper between pages" PickOne DocumentSetup 0
  *Choice "None" "<</cupsInteger0  0>>setpagedevice"
  Choice "1mm"   "<</cupsInteger0  1>>setpagedevice"
  Choice "2mm"   "<</cupsInteger0  2>>setpagedevice"
  Choice "5mm"   "<</cupsInteger0  5>>setpagedevice"
  Choice "10mm" "<</cupsInteger0 10>>setpagedevice"

Option "PageMark/Mark where to cut pages" Boolean DocumentSetup 1
  *Choice "No" "<</cupsInteger1 0>>setpagedevice"
  Choice "Yes" "<</cupsInteger1 1>>setpagedevice"

Option "EjectFeed/Feed paper after printing" PickOne DocumentSetup 2
  Choice "None"  "<</cupsInteger2  0>>setpagedevice"
  *Choice "5mm"  "<</cupsInteger2  5>>setpagedevice"
  Choice "10mm" "<</cupsInteger2 10>>setpagedevice"
  
Option "AutoCrop/Crop page to printed area" Boolean DocumentSetup 3
  *Choice "No" "<</cupsInteger3 0>>setpagedevice"
  Choice "Yes" "<</cupsInteger3 1>>setpagedevice"

The *’s indicate the default choices. And this so far appears to work! The web interface shows this:

And the print dialog in Firefox looks like this:

Sweet!

Writing a valid CUPS filter

This is actually documented reasonably well if you know where to look. I believe I can ignore all arguments (I’m using the other method for options, and I’ve told the driver I don’t know how to make copies myself) except the optional argv[6] which is the file to print if it’s not stdin. Yay.

Cancellation is easy: ignore SIGPIPE and clean up on SIGTERM. Since it’s a simple program, I can use a simple solution where I just poll a global variable:

volatile sig_atomic_t cancel_job = 0;
//...
	signal(SIGPIPE, SIG_IGN);
	signal(SIGTERM, [](int){ cancel_job = 1;});

Logging likewise is easy and involves writing to stderr something like TYPE: data where TYPE is the message type. The type has things such as ERROR, DEBUG, etc for logging, PAGE for recording the current page number, STATE for indicating things like paper empty and so on. The format of the data depends on the message type.

Paper empty and so on can be queried from the printer using special control codes and CUPS looks like it has a way to read back anything returned. I’m not so sure how this works yet. I’ll deal with that later.

Dealing with options took me far too long. I started with the following code snippet:

	cups_raster_t *ras;
	cups_page_header2_t header;
	//...
	while (cupsRasterReadHeader2(ras, &header))
	{
		feed_between_pages_mm = header.cupsInteger[0];
		mark_page_boundary = header.cupsInteger[1];
		eject_after_print_mm = header.cupsInteger[2];
		auto_crop = header.cupsInteger[3];
		enhance_resolution = header.cupsInteger[4];

and it didn’t really work. And by “didn’t work”, I mean that I tried adding -dcupsInteger0=1 to the GhostScript invocation (this sets an integer variable and somehow these magically wind up in setpagedevice, I don’t know how) and I could only set 0, 1 and 2. None of the other integers could be set.

If you cast your mind back to the first post in this series, I mentioned that I cargo-culted an invocation of GhostScript and wasn’t sure what everything did. Well, it came to bite me here. It has the innocuous looking argument -sMediaClass=PwgRaster (-s just sets a variable in the interpreter). This is now getting in quite deep. MediaClass is a variable which affects the setpagedevice command (page 21 of the PostScript® Language Reference Manual Supplement published in 1996 on April 1 and it is deadly serious) in various nonspecific (vendor defined) ways. And one such vendor is the shadowy cabal known as the “Printer Working Group” or PWG for short (its more exciting if they are a shadowy cabal). I sort of unearthed them by forlornly digging through cups/raster.h looking for clues and found this (edited) for display:

// The following PWG 5102.4 definitions specify indices into the
// cupsInteger[] array in the raster header.
#  define CUPS_RASTER_PWG_TotalPageCount	0
#  define CUPS_RASTER_PWG_CrossFeedTransform	1
// etc...
#  define CUPS_RASTER_PWG_VendorLength		15

Turns out they have defined their own meanings for the user-defined extensions and brazenly took all of them. What I don’t understand is why I could set 0, 1 and 2, but not 3 onwards. No clues there. It also stopped cupsReal and cupsString from working and set PWG_AlternatePrimary to 224-1. ¯\_(ツ)_/¯

What went wrong

So that all sort of worked, and I can print out cats using lpr. Except…

Inverted cats. And junk

The cats come out inverted, like this:

meow!

This is because I had:

*Resolution k 8 0 0 0 "203dpi/203 DPI"

which is the “black” colour model. If I change the “k” to “w”, I get what I expect except with some junk at the top.

What I actually need is:

*ColorModel Gray/Grayscale w chunky 0
*Resolution - 8 0 0 0 "203dpi/203 DPI"

I don’t know why. The colour model specifies the white model (along with chunky which is means packed for colour data and no compression), then the resolution says to not modify the colour model. Ok, sure…

Nope!!

Turns out that wasn’t it. I must have just reset things when making that change. The junk was because… well I don’t know exactly. It doesn’t appear on the first printout, it only appears on the third. And if I send enough text to the printer then the next image is fine. It therefore appears as if something was getting flushed before the last line was complete. Then the first few bytes (including the start bitmap control code) were getting eaten up finishing the previous line and then it was printing data out as text.

Turns out the offending bit was this function

void printerInitialise(){
	cout << ESC << '\x40';
}

calls to which I sprinkled liberally around, and these are messing things up. Here’s the funny thing though: putting a cout << flush after the first one fixed it. That ought to make sense: the printer gets data asynchronously then starts processing it while the UART asynchronously fills the receive buffer. It processes the initialise command and loses the first few control codes. Or something.

Except… the symptoms only manifested after several images, making it look like it was state being carried over. It’s weird, I don’t get it. Clearly there’s some internal state somewhere, and part of me things is might be in CUPS because I suspect the original driver used to work just fine.

Page Sizes

The print dialog boxes seemed to get deeply confused about the smallest page size (58x50mm). The reason for this it turns out is that it’s really a landscape page not a portrait one and pages need to be specified in portrait orientation. Except that would make the width wrong. If I’d paid attention to the warnings from cupstestppd, then I would not have had this problem.

ppd/mini.ppd: PASS
        WARN    Size "58x50mm" should be the Adobe standard name "50x58mmRotated".

And it turns out all I have to do is switch the name:

#media "50x58mmRotated" 58mm 50mm
#media "58x100mm" 58mm 100mm
#media "58x200mm" 58mm 200mm

HWMargins 5mm 0 5mm 0

*MediaSize "50x58mmRotated"
MediaSize "58x100mm"
MediaSize "58x200mm"

and things seem to be much more sensible.

Booleans

The print dialog box renderers don’t really know which option is meant to correspond to a check mark and which isn’t. I tried changing the keyword to “True” and “False” and putting true first in the list, e.g.:

Option "PageMark/Mark where to cut pages" Boolean DocumentSetup 1
  Choice "True/Yes" "<</cupsInteger1 1>>setpagedevice"
  *Choice "False/No" "<</cupsInteger1 0>>setpagedevice"

That seemed to do the job. I believe it’s the ordering that matters, I’m not sure though.

Other stuff

There were a few other miscellaneous bits and bobs to fix too. In addition I implemented the various features I mentioned above. I decided also to emit blank lines as a feed rather than a blank line because it’s a fair bit faster. Except I had to suppress that in enhanced resolution mode, because otherwise the first few lines printed after a gap were too dark.

I also want the printer to print plain text as plain text. This isn’t necessary but it’s always been idiomatic to pass through like that, rather than relying on the postscript rasteriser. I can fix that with one extra line in the DRV file:

Filter text/plain 0 -

That tells CUPS that it accepts text, is no cost and to use a null filter program.

Cancellation

Oh wow this turned out to be hard. Way harder than expected because it reveals deep problems. It’s going to be a whole other blog post.

Result!

OK so basically it works!

I can print using lp (or lpr), and set options like -o Enhance=True -o PageMark=True and it obeys them.

Recognise this?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s