Skip to content

Commit c0207cf

Browse files
authored
Merge pull request #1846 from jepler/cat-thermal-printer
Cat thermal printer
2 parents a39464d + b849f2b commit c0207cf

File tree

7 files changed

+390
-0
lines changed

7 files changed

+390
-0
lines changed
30.6 KB
Binary file not shown.
18.4 KB
Binary file not shown.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import os
2+
3+
import board
4+
import keypad
5+
import ulab.numpy as np
6+
7+
from adafruit_ble import BLERadio
8+
from adafruit_ble.advertising import Advertisement
9+
10+
from thermalprinter import CatPrinter
11+
from seekablebitmap import imageopen
12+
13+
ble = BLERadio() # pylint: disable=no-member
14+
15+
buttons = keypad.Keys([board.BUTTON_A, board.BUTTON_B], value_when_pressed=False)
16+
17+
def wait_for_press(kbd):
18+
"""
19+
Wait for a keypress and return the event
20+
"""
21+
while True:
22+
event = kbd.events.get()
23+
if event and event.pressed:
24+
return event
25+
26+
27+
def show(s):
28+
"""
29+
Display a message on the screen
30+
"""
31+
board.DISPLAY.auto_refresh = False
32+
print("\n" * 24)
33+
print(s)
34+
board.DISPLAY.auto_refresh = True
35+
36+
37+
def show_error(s):
38+
"""
39+
Display a message on the screen and wait for a button press
40+
"""
41+
show(s + "\nPress a button to continue")
42+
wait_for_press(buttons)
43+
44+
45+
def find_cat_printer(radio):
46+
"""
47+
Connect to the cat printer device using BLE
48+
"""
49+
while True:
50+
show("Scanning for GB02 device...")
51+
for adv in radio.start_scan(Advertisement):
52+
complete_name = getattr(adv, "complete_name")
53+
if complete_name is not None:
54+
print(f"Saw {complete_name}")
55+
if complete_name == "GB02":
56+
radio.stop_scan()
57+
return radio.connect(adv, timeout=10)[CatPrinter]
58+
59+
60+
image_files = [
61+
i
62+
for i in os.listdir("/")
63+
if i.lower().endswith(".pbm") or i.lower().endswith(".bmp")
64+
]
65+
image_files.sort(key=lambda filename: filename.lower())
66+
67+
68+
def select_image():
69+
i = 0
70+
while True:
71+
show(
72+
f"Select image file\nA: next image\nB: print this image\n\n{image_files[i]}"
73+
)
74+
event = wait_for_press(buttons)
75+
if event.key_number == 0: # button "A"
76+
i = (i + 1) % len(image_files)
77+
if event.key_number == 1: # button "B"
78+
return image_files[i]
79+
80+
81+
printer = find_cat_printer(ble)
82+
83+
def main():
84+
try:
85+
filename = select_image()
86+
87+
show(f"Loading {filename}")
88+
89+
image = imageopen(filename)
90+
if image.width != 384:
91+
raise ValueError("Invalid image. Must be 384 pixels wide")
92+
if image.bits_per_pixel != 1:
93+
raise ValueError("Invalid image. Must be 1 bit per pixel (black & white)")
94+
95+
invert_image = image.palette and image.palette[0] == 0
96+
97+
show(f"Printing {filename}")
98+
99+
for i in range(image.height):
100+
row_data = image.get_row(i)
101+
if invert_image:
102+
row_data = ~np.frombuffer(row_data, dtype=np.uint8)
103+
printer.print_bitmap_row(row_data)
104+
105+
# Print blank lines until the paper can be torn off
106+
for i in range(80):
107+
printer.print_bitmap_row(b"\0" * 48)
108+
109+
except Exception as e: # pylint: disable=broad-except
110+
show_error(str(e))
111+
image_files.remove(filename)
112+
113+
while True:
114+
main()
26.3 KB
Binary file not shown.
30.1 KB
Binary file not shown.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import struct
2+
3+
class SeekableBitmap:
4+
"""Allow random access to an uncompressed bitmap file on disk"""
5+
def __init__(
6+
self,
7+
image_file,
8+
width,
9+
height,
10+
bits_per_pixel,
11+
*,
12+
bytes_per_row=None,
13+
data_start=None,
14+
stride=None,
15+
palette=None,
16+
):
17+
"""Construct a SeekableBitmap"""
18+
self.image_file = image_file
19+
self.width = width
20+
self.height = height
21+
self.bits_per_pixel = bits_per_pixel
22+
self.bytes_per_row = (
23+
bytes_per_row if bytes_per_row else (bits_per_pixel * width + 7) // 8
24+
)
25+
self.stride = stride if stride else self.bytes_per_row
26+
self.palette = palette
27+
self.data_start = data_start if data_start else image_file.tell()
28+
29+
def get_row(self, row):
30+
self.image_file.seek(self.data_start + row * self.stride)
31+
return self.image_file.read(self.bytes_per_row)
32+
33+
34+
def _pnmopen(filename):
35+
"""
36+
Scan for netpbm format info, skip over comments, and read header data.
37+
38+
Return the format, header, and the opened file positioned at the start of
39+
the bitmap data.
40+
"""
41+
# pylint: disable=too-many-branches
42+
image_file = open(filename, "rb")
43+
magic_number = image_file.read(2)
44+
image_file.seek(2)
45+
pnm_header = []
46+
next_value = bytearray()
47+
while True:
48+
# We have all we need at length 3 for formats P2, P3, P5, P6
49+
if len(pnm_header) == 3:
50+
return image_file, magic_number, pnm_header
51+
52+
if len(pnm_header) == 2 and magic_number in [b"P1", b"P4"]:
53+
return image_file, magic_number, pnm_header
54+
55+
next_byte = image_file.read(1)
56+
if next_byte == b"":
57+
raise RuntimeError("Unsupported image format {}".format(magic_number))
58+
if next_byte == b"#": # comment found, seek until a newline or EOF is found
59+
while image_file.read(1) not in [b"", b"\n"]: # EOF or NL
60+
pass
61+
elif not next_byte.isdigit(): # boundary found in header data
62+
if next_value:
63+
# pull values until space is found
64+
pnm_header.append(int("".join(["%c" % char for char in next_value])))
65+
next_value = bytearray() # reset the byte array
66+
else:
67+
next_value += next_byte # push the digit into the byte array
68+
69+
70+
def pnmopen(filename):
71+
"""
72+
Interpret netpbm format info and construct a SeekableBitmap
73+
"""
74+
image_file, magic_number, pnm_header = _pnmopen(filename)
75+
if magic_number == b"P4":
76+
return SeekableBitmap(
77+
image_file,
78+
pnm_header[0],
79+
pnm_header[1],
80+
1,
81+
palette=b"\xff\xff\xff\x00\x00\x00\x00\x00",
82+
)
83+
if magic_number == b"P5":
84+
return SeekableBitmap(
85+
image_file, pnm_header[0], pnm_header[1], pnm_header[2].bit_length()
86+
)
87+
if magic_number == b"P6":
88+
return SeekableBitmap(
89+
image_file, pnm_header[0], pnm_header[1], 3 * pnm_header[2].bit_length()
90+
)
91+
raise ValueError(f"Unknown or unsupported magic number {magic_number}")
92+
93+
94+
def bmpopen(filename):
95+
"""
96+
Interpret bmp format info and construct a SeekableBitmap
97+
"""
98+
image_file = open(filename, "rb")
99+
100+
header = image_file.read(34)
101+
102+
data_start, header_size, width, height, _, bits_per_pixel, _ = struct.unpack(
103+
"<10x4l2hl", header
104+
)
105+
106+
bits_per_pixel = bits_per_pixel if bits_per_pixel != 0 else 1
107+
108+
palette_start = header_size + 14
109+
image_file.seek(palette_start)
110+
palette = image_file.read(4 << bits_per_pixel)
111+
112+
stride = (bits_per_pixel * width + 31) // 32 * 4
113+
if height < 0:
114+
height = -height
115+
else:
116+
data_start = data_start + stride * (height - 1)
117+
stride = -stride
118+
119+
return SeekableBitmap(
120+
image_file,
121+
width,
122+
height,
123+
bits_per_pixel,
124+
data_start=data_start,
125+
stride=stride,
126+
palette=palette,
127+
)
128+
129+
130+
def imageopen(filename):
131+
"""
132+
Open a bmp or pnm file as a seekable bitmap
133+
"""
134+
if filename.lower().endswith(".bmp"):
135+
return bmpopen(filename)
136+
return pnmopen(filename)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Protocol information from Thermal_Printer Arduino library
2+
# https://github.com/bitbank2/Thermal_Printer/
3+
from adafruit_ble.uuid import StandardUUID
4+
from adafruit_ble.services import Service
5+
from adafruit_ble.characteristics.stream import StreamIn
6+
7+
# Switch the printing mode to bitmap
8+
printimage = b"Qx\xbe\x00\x01\x00\x00\x00\xff"
9+
# Switch the printing mode to text
10+
printtext = b"Qx\xbe\x00\x01\x00\x01\x07\xff"
11+
# Command to feed paper
12+
paperfeed = b"Qx\xa1\x00\x02\x00\x1eZ\xff\xff"
13+
14+
# this table helps compute the checksum of transmitted data
15+
# it is crc-8-ccitt
16+
checksumtable = (
17+
b"\x00\x07\x0e\t\x1c\x1b\x12\x158?61$#*-"
18+
b"pw~ylkbeHOFATSZ]"
19+
b"\xe0\xe7\xee\xe9\xfc\xfb\xf2\xf5\xd8\xdf\xd6\xd1\xc4\xc3\xca\xcd"
20+
b"\x90\x97\x9e\x99\x8c\x8b\x82\x85\xa8\xaf\xa6\xa1\xb4\xb3\xba\xbd"
21+
b"\xc7\xc0\xc9\xce\xdb\xdc\xd5\xd2\xff\xf8\xf1\xf6\xe3\xe4\xed\xea"
22+
b"\xb7\xb0\xb9\xbe\xab\xac\xa5\xa2\x8f\x88\x81\x86\x93\x94\x9d\x9a"
23+
b"' ).;<52\x1f\x18\x11\x16\x03\x04\r\n"
24+
b"WPY^KLEBohafst}z"
25+
b"\x89\x8e\x87\x80\x95\x92\x9b\x9c\xb1\xb6\xbf\xb8\xad\xaa\xa3\xa4"
26+
b"\xf9\xfe\xf7\xf0\xe5\xe2\xeb\xec\xc1\xc6\xcf\xc8\xdd\xda\xd3\xd4"
27+
b"ing`ur{|QV_XMJCD"
28+
b"\x19\x1e\x17\x10\x05\x02\x0b\x0c!&/(=:34"
29+
b"NI@GRU\\[vqx\x7fjmdc"
30+
b'>907"%,+\x06\x01\x08\x0f\x1a\x1d\x14\x13'
31+
b"\xae\xa9\xa0\xa7\xb2\xb5\xbc\xbb\x96\x91\x98\x9f\x8a\x8d\x84\x83"
32+
b"\xde\xd9\xd0\xd7\xc2\xc5\xcc\xcb\xe6\xe1\xe8\xef\xfa\xfd\xf4\xf3"
33+
)
34+
35+
# mirrortable[i] is the bit reversed version of the byte i
36+
mirrortable = (
37+
b"\x00\x80@\xc0 \xa0`\xe0\x10\x90P\xd00\xb0p\xf0"
38+
b"\x08\x88H\xc8(\xa8h\xe8\x18\x98X\xd88\xb8x\xf8"
39+
b"\x04\x84D\xc4$\xa4d\xe4\x14\x94T\xd44\xb4t\xf4"
40+
b"\x0c\x8cL\xcc,\xacl\xec\x1c\x9c\\\xdc<\xbc|\xfc"
41+
b'\x02\x82B\xc2"\xa2b\xe2\x12\x92R\xd22\xb2r\xf2'
42+
b"\n\x8aJ\xca*\xaaj\xea\x1a\x9aZ\xda:\xbaz\xfa"
43+
b"\x06\x86F\xc6&\xa6f\xe6\x16\x96V\xd66\xb6v\xf6"
44+
b"\x0e\x8eN\xce.\xaen\xee\x1e\x9e^\xde>\xbe~\xfe"
45+
b"\x01\x81A\xc1!\xa1a\xe1\x11\x91Q\xd11\xb1q\xf1"
46+
b"\t\x89I\xc9)\xa9i\xe9\x19\x99Y\xd99\xb9y\xf9"
47+
b"\x05\x85E\xc5%\xa5e\xe5\x15\x95U\xd55\xb5u\xf5"
48+
b"\r\x8dM\xcd-\xadm\xed\x1d\x9d]\xdd=\xbd}\xfd"
49+
b"\x03\x83C\xc3#\xa3c\xe3\x13\x93S\xd33\xb3s\xf3"
50+
b"\x0b\x8bK\xcb+\xabk\xeb\x1b\x9b[\xdb;\xbb{\xfb"
51+
b"\x07\x87G\xc7'\xa7g\xe7\x17\x97W\xd77\xb7w\xf7"
52+
b"\x0f\x8fO\xcf/\xafo\xef\x1f\x9f_\xdf?\xbf\x7f\xff"
53+
)
54+
55+
56+
def checksum(data, start, count):
57+
cs = 0
58+
for i in range(start, start + count):
59+
cs = checksumtable[cs ^ data[i]]
60+
return cs
61+
62+
63+
MODE_TEXT = "MODE_TEXT"
64+
MODE_BITMAP = "MODE_BITMAP"
65+
66+
class CatPrinter(Service):
67+
68+
uuid = StandardUUID(0xAE30)
69+
70+
_tx = StreamIn(uuid=StandardUUID(0xAE01), timeout=1.0, buffer_size=256)
71+
72+
def _write_data(self, buf):
73+
self._tx.write(buf)
74+
75+
@property
76+
def bitmap_width(self):
77+
return 384
78+
79+
def __init__(self, service=None):
80+
super().__init__(service=service)
81+
self._mode = None
82+
83+
@property
84+
def mode(self):
85+
return self._mode
86+
87+
@mode.setter
88+
def mode(self, value):
89+
if value == self.mode:
90+
return
91+
92+
if value == MODE_TEXT:
93+
self._write_data(printtext)
94+
elif value == MODE_BITMAP:
95+
self._write_data(printimage)
96+
else:
97+
raise ValueError("Invalid mode %r" % value)
98+
99+
self._mode = value
100+
101+
def feed_lines(self, lines):
102+
buf = bytearray(paperfeed)
103+
buf[6] = lines & 0xFF
104+
buf[7] = lines >> 8
105+
buf[8] = checksum(buf, 6, 2)
106+
self._write_data(buf)
107+
108+
def _print_common(self, text, reverse_bits=True):
109+
data = memoryview(text)
110+
while data:
111+
sz = min(112, len(data))
112+
sub_data = data[:sz]
113+
data = data[sz:]
114+
buf = bytearray(sz + 8)
115+
buf[0] = 0x51
116+
buf[1] = 0x78
117+
buf[2] = 0xA2
118+
buf[3] = 0x0
119+
buf[4] = sz
120+
buf[5] = 0
121+
if reverse_bits:
122+
buf[6 : 6 + sz] = bytes(mirrortable[c] for c in sub_data)
123+
else:
124+
buf[6 : 6 + sz] = sub_data
125+
buf[6 + sz] = checksum(buf, 6, len(sub_data))
126+
buf[6 + sz + 1] = 0xFF
127+
128+
self._write_data(buf)
129+
130+
def print_text(self, text):
131+
self.mode = MODE_TEXT
132+
self._print_common(text.encode("utf-8"))
133+
134+
def print_line(self, text):
135+
self.print_text(text)
136+
self._print_common(b"\n")
137+
138+
def print_bitmap_row(self, data, reverse_bits=True):
139+
self.mode = MODE_BITMAP
140+
self._print_common(data, reverse_bits)

0 commit comments

Comments
 (0)