-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgp58_printer.py
More file actions
169 lines (137 loc) · 5.01 KB
/
Copy pathgp58_printer.py
File metadata and controls
169 lines (137 loc) · 5.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
#!/usr/bin/env python3
"""Linux USB helpers for small GP-58/ESC-POS thermal printers.
The host usually exposes this class of printer as /dev/usb/lp0. The functions
here intentionally keep to a conservative ESC/POS subset: text, line feed, and
GS v 0 raster images.
"""
from __future__ import annotations
import os
import textwrap
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
WIDTH_CHARS = 32
WIDTH_DOTS = 384
DEFAULT_DEVICE = "/dev/usb/lp0"
@dataclass(frozen=True)
class PrinterStatus:
device: str
exists: bool
readable: bool = False
writable: bool = False
mode: str | None = None
uid: int | None = None
gid: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
"device": self.device,
"exists": self.exists,
"readable": self.readable,
"writable": self.writable,
"mode": self.mode,
"uid": self.uid,
"gid": self.gid,
}
def device_status(device: str = DEFAULT_DEVICE) -> PrinterStatus:
path = Path(device)
if not path.exists():
return PrinterStatus(device=device, exists=False)
st = path.stat()
return PrinterStatus(
device=device,
exists=True,
readable=os.access(device, os.R_OK),
writable=os.access(device, os.W_OK),
mode=oct(st.st_mode & 0o777),
uid=st.st_uid,
gid=st.st_gid,
)
def encode_text(text: str, encoding: str = "cp437") -> bytes:
return text.encode(encoding, errors="replace")
def fit_line(text: str, *, width: int = WIDTH_CHARS, align: str = "left") -> str:
text = text[:width]
if align == "center":
return text.center(width)
if align == "right":
return text.rjust(width)
return text.ljust(width)
def wrapped_lines(text: str, *, width: int = WIDTH_CHARS) -> list[str]:
lines: list[str] = []
for paragraph in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if not paragraph:
lines.append("")
continue
lines.extend(textwrap.wrap(paragraph, width=width, replace_whitespace=False) or [""])
return lines
def build_text_receipt(
text: str,
*,
title: str = "",
footer: str = "",
feed_lines: int = 4,
encoding: str = "cp437",
) -> bytes:
payload = bytearray()
payload += b"\x1b@" # initialize
if title:
payload += b"\x1ba\x01" # center
payload += b"\x1bE\x01" # bold on
payload += encode_text(fit_line(title.upper(), align="center") + "\n", encoding)
payload += b"\x1bE\x00" # bold off
payload += b"\x1ba\x00" # left
payload += encode_text("-" * WIDTH_CHARS + "\n", encoding)
for line in wrapped_lines(text):
payload += encode_text(fit_line(line.rstrip()) + "\n", encoding)
if footer:
payload += encode_text("-" * WIDTH_CHARS + "\n", encoding)
payload += encode_text(fit_line(footer, align="center") + "\n", encoding)
payload += b"\n" * max(0, min(feed_lines, 12))
return bytes(payload)
def build_random_receipt(text: str) -> bytes:
return build_text_receipt(
text,
title="HERMES",
footer=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
)
def image_to_raster_payload(path: Path, *, max_width: int = WIDTH_DOTS, feed_lines: int = 4) -> bytes:
from PIL import Image, ImageOps
image = Image.open(path)
image = ImageOps.exif_transpose(image).convert("L")
if image.width > max_width:
ratio = max_width / image.width
image = image.resize((max_width, max(1, round(image.height * ratio))), Image.LANCZOS)
image = ImageOps.autocontrast(image, cutoff=1).convert("1")
width_bytes = (image.width + 7) // 8
height = image.height
if width_bytes > 255:
raise ValueError(f"image is too wide for ESC/POS raster mode: {image.width}px")
if height > 65535:
raise ValueError(f"image is too tall for ESC/POS raster mode: {height}px")
payload = bytearray()
payload += b"\x1b@"
payload += b"\x1dv0\x00"
payload += bytes([width_bytes & 0xFF, (width_bytes >> 8) & 0xFF, height & 0xFF, (height >> 8) & 0xFF])
pixels = image.load()
for y in range(height):
for xb in range(width_bytes):
byte = 0
for bit in range(8):
x = xb * 8 + bit
if x < image.width and pixels[x, y] == 0:
byte |= 0x80 >> bit
payload.append(byte)
payload += b"\n" * max(0, min(feed_lines, 12))
return bytes(payload)
def write_device(device: str, payload: bytes, *, chunk_size: int = 512, delay_ms: int = 0) -> None:
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
fd = os.open(device, os.O_WRONLY)
try:
for offset in range(0, len(payload), chunk_size):
os.write(fd, payload[offset : offset + chunk_size])
if delay_ms:
time.sleep(delay_ms / 1000.0)
finally:
os.close(fd)