Files
littleprynter/src/printer.py
2026-11-12 15:34:03 +01:00

664 lines
21 KiB
Python

"""
This class manages connexion to a Printer
"""
import os.path
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
import time
from enum import Enum
import uuid
import threading
import usb.core
from pathlib import Path
from uuid import uuid4
from PIL import Image, ImageEnhance, ImageDraw, ImageFont
import numpy as np
# Importing the modules needed for each supported printer Type
import escpos.printer
from brother_ql.models import ModelsManager
from brother_ql.backends import backend_factory, guess_backend
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
from brother_ql import create_label
class PrinterType(Enum):
"""
What are the capacities of a Printer ?
"""
EPSON = "epson"
BROTHER = "brother"
# For Brother-QL Printers
@dataclass
class PrinterInfo:
"""
Brother-QL printer information
"""
identifier: str
backend: str
protocol: str
vendor_id: str
product_id: str
serial_number: str
name: str = "Brother QL Printer"
model: str = "QL-570"
status: str = "unknown"
label_type: str = "unknown"
label_size: str = "unknown"
label_width: int = 0
label_height: int = 0
def __getitem__(self, item):
return getattr(self, item)
def __setitem__(self, key, value):
setattr(self, key, value)
class Printer(ABC):
"""
If it outputs printed paper and speaks like a printer, then it must be a printer.
"""
def __init__(self, app, vendor_id, device_id, printer_type: PrinterType):
"""
We initialize a Printer via it's USB connexion, and generate a unique ID
"""
self.id = uuid.uuid4()
self.app = app
self.vendor_id = vendor_id
self.device_id = device_id
self.ready = False
self.printer_type = printer_type
self._lock = threading.Lock()
self.used = False
@abstractmethod
def _has_paper(self) -> bool:
"""Check if the printer has papier"""
@abstractmethod
def _state(self) -> bool:
"""Reports the state of the Printer"""
@abstractmethod
def print_task(self, task_type, data) -> None:
"""Takes a PrintTask and executes it"""
def use_printer(self) -> None:
"""Marks the printer as being used """
self.used = True
def free_printer(self) -> None:
"""Marks the printer as being not used, it's available."""
self.used = False
class EscPosPrinter(Printer):
"""
Create a new ESC/POS based printer.
"""
def __init__(self, app, vendor_id, device_id):
"""
Create a connexion to a ESC/POS Printer via USB,
Making sure the printer is alive,
Making sure it has paper,
Define default print settings
"""
super().__init__(app, vendor_id, device_id, printer_type=PrinterType.EPSON)
self.printer = None
self.usb_args = {}
self.usb_args["idVendor"] = self.vendor_id
self.usb_args["idProduct"] = self.device_id
try:
# This also calls open(), which we need to close()
# or else the device will appear as busy.
p = escpos.printer.Usb(self.vendor_id, self.device_id, 0, profile="TM-P80")
except escpos.exceptions.DeviceNotFoundError as e:
self.app.logger.error(
"The USB device is not plugged in : %s",
str(e),
)
except Exception as e:
self.app.logger.error("Printer could not be connected : %s ", str(e))
try:
if p.is_online():
self.app.logger.debug("Printer online !")
except Exception as e:
raise e
# Setting up the printing options.
p.set(
align="center",
font="a",
bold=False,
underline=0,
width=1,
height=1,
density=9,
invert=False,
smooth=False,
flip=False,
double_width=False,
double_height=False,
custom_size=False,
)
# Beware : if we print every time the printer becomes ready, it means
# we are printing before and after every print !
self.printer = p
self.printer.close() # We close the connexion to the Printer
try:
self._has_paper()
except Exception as e:
raise e
self.ready = True
def _has_paper(self):
"""Check if the printer has paper left"""
self.app.logger.debug("Checking paper status...")
self.printer.open(self.usb_args)
status = self.printer.paper_status()
match status:
case 0:
self.app.logger.error("Printer has no more paper, aborting...")
self.printer.close()
raise RuntimeError("No more paper in the printer")
case 1:
self.app.logger.warning(
"Printer needs paper to be changed very soon ! "
)
self.printer.close()
return True
case 2:
self.app.logger.debug("Printer has paper, good to go")
self.printer.close()
return True
def _print_txt(self, msg, signature="", bold=False):
self.ready = False
if not isinstance(msg, str):
self.app.logger.error(
"It is not possible to print a " + str(type(msg)) + ", only strings."
)
raise ValueError
# We make sure that the signature is not something too goofy
clean_msg = str(msg) + "\n"
clean_signature = str(signature)
# Make checks on the size of the message being printed
if len(clean_msg) > 4096:
self.app.logger.warning(
"Could not print message of this length: " + str(len(clean_msg))
)
raise RuntimeError(
"Could not print message of this length :"
+ str(len(clean_msg))
+ ", needs to be below 4096 caracters long."
)
if len(signature) > 256:
self.app.logger.warning(
"Could not print signature of this length: " + str(len(clean_signature))
)
raise RuntimeError(
"Could not print signature of this length :"
+ str(len(clean_signature))
+ ", needs to be below 256 caracters long."
)
# Do the actual printing
# We would pop the next element in the queue here, if it's a sms type
try:
self.printer.open(self.usb_args)
self.printer.set(align="center", font="a", bold=bold)
self.printer.textln(clean_msg)
if clean_signature:
self.printer.textln(clean_signature)
self.printer.textln()
self.printer.close()
except Exception as e:
self.app.logger.error("Unable to print because : " + str(e))
raise RuntimeError(
"Unable to print a SMS, the printer couldn't do it."
) from e
self.app.logger.info("Printed text")
self.ready = True
def _print_img(self, path, signature="", center=True, process=False):
self.ready = False
clean_signature = str(signature)
if len(signature) > 256:
self.app.logger.warning(
"Could not print signature of this length: " + str(len(clean_signature))
)
raise ValueError(
"Could not print signature of this length :"
+ str(len(clean_signature))
+ ", needs to be below 256 caracters long."
)
if not os.path.isfile(str(path)):
self.app.logger.warning("File does not exist : " + str(path))
raise OSError(
"The file path for this image :"
+ str(path)
+ " wasn't found. Please try again."
)
self.app.logger.debug("Printing file from " + str(path))
if process:
try:
self.app.logger.debug("Proccessing the image")
processed_path = _process_image(self, path)
except RuntimeError as e:
self.app.logger.error(
"Error while processing the image, aborting print : %s", str(e)
)
raise e
else:
processed_path = path
self.app.logger.warning("Not proccessing the image")
try:
self.printer.open(self.usb_args)
self.printer.image(processed_path, center=center)
self.printer.textln(signature)
self.printer.close()
self.app.logger.debug("Printed an image : " + str(processed_path))
except Exception as e:
self.app.logger.error(str(e))
raise RuntimeError("Could not print the picture") from e
finally:
try:
os.remove(path)
os.remove(processed_path)
except OSError as e:
raise e
self.app.logger.debug("Removed image : " + str(processed_path))
self.app.logger.debug("Removed image : " + str(path))
try:
self.printer.close()
except Exception as e:
self.app.logger.error(
"Could not close the printer connexion %s", str(e)
)
raise RuntimeError("Could not close the printer connexion. ") from e
self.app.logger.info("Printed a picture")
self.ready = True
def _qr(self, content):
self.ready = False
try:
self.printer.open(self.usb_args)
self.printer.qr(content, center=True)
self.printer.textln(content)
self.printer.close()
except RuntimeError as e:
self.printer.close()
self.app.logger.error(str(e))
raise e
self.app.logger.info("Printed a QR")
self.ready = True
def _cut(self):
self.ready = False
try:
self.printer.open(self.usb_args)
self.printer.cut()
self.printer.close()
except Exception as e:
self.printer.close()
self.app.logger.error(str(e))
raise e
self.app.logger.info("Did a cut")
self.ready = True
def _state(self) -> bool:
has_paper = self._has_paper()
is_ready = self.ready
self.app.logger.debug("Has paper : %s " , has_paper )
self.app.logger.debug("Ready : %s " , is_ready )
return is_ready and has_paper # and is_online
def print_task(self, task_type, data):
"""Execute actual print based on task type"""
with self._lock:
self.app.logger.debug("Acquired lock to start print")
i_m_ready = self._state()
while not i_m_ready:
self.app.logger.debug("Waiting for the printer to become ready, current state %s ", str(i_m_ready))
i_m_ready = self._state()
time.sleep(0.3)
self.app.logger.debug("Checked state to start printing : %s", self._state())
self.ready = False
try:
self.app.logger.debug("Checking task type")
match (task_type.value):
case "text":
self._print_txt(data["txt"], signature=data["sign"])
self.ready = True
case "image":
self._print_img(
data["img"], signature=data["sign"], process=data["process"]
)
self.ready = True
case "cut":
self._cut()
self.ready = True
case "qr":
self._qr(data["txt"])
self.ready = True
case _:
raise RuntimeError("This task type is not supported")
except Exception as e:
self.app.logger.debug("Exception occured while printing %s", str(e))
self.ready = True
raise RuntimeError from e
class BrotherPrinter(Printer):
"""
Manages connexion and capabilities of a BrotherQL Printer
"""
def __init__(self, app, vendor_id, device_id):
super().__init__(
app, vendor_id, device_id, printer_type=PrinterType.BROTHER
)
self.printer = None
self.usb_args = {}
self.usb_args["idVendor"] = self.vendor_id
self.usb_args["idProduct"] = self.device_id
self.model_manager = ModelsManager()
# Code taken from https://github.com/5shekel/printit/blob/master/printer_utils.py
self.app.logger.debug("Creating backend for Brother")
backend = backend_factory("pyusb")
available_devices = backend["list_available_devices"]()
for printer in available_devices:
self.app.logger.debug(f"Found device: {printer}")
identifier = printer["identifier"]
parts = identifier.split("/")
if len(parts) < 4:
self.app.logger.warning(
f"Skipping device with invalid identifier format: {identifier}"
)
continue
protocol = parts[0]
# device_info = parts[2]
serial_number = parts[3]
try:
for m in self.model_manager.iter_elements():
if m.product_id == self.device_id:
model = m.identifier
break
self.app.logger.debug(f"Matched printer model: {model}")
except ValueError as ve:
self.app.logger.warning("Invalid product ID format:"+ str(ve))
raise ve
except Exception as e:
self.app.logger.error("Error while finding a model ? : " + str(e))
raise e
self.printer_info = PrinterInfo(
identifier=identifier,
backend="pyusb",
model=model,
protocol=protocol,
vendor_id=vendor_id,
product_id=self.device_id,
serial_number=serial_number,
label_type="62",
)
self.app.logger.debug("Created a Brother printer !")
self.ready = True
def _has_paper(self):
raise NotImplementedError("This printer model does not support this.")
def _state(self):
return self.ready
def _print_txt(self, msg, signature="", bold=False):
"""Print text after converting it to an image"""
self._print_img(text_to_image(msg))
def _print_img(self, data):
"""
Print a raster image via a Brother QL printer
"""
self.ready = False
try:
selected_backend = guess_backend(self.printer_info.identifier)
BACKEND_CLASS = backend_factory(selected_backend)['backend_class']
BACKEND_STRING_DESCR = self.printer_info.identifier
except Exception as e:
self.app.logger.error("Couldn't guess the backend to use from the printer string descriptor")
try:
im = Image.open(data)
qlr = BrotherQLRaster(self.printer_info.model)
create_label(qlr, im, self.printer_info.label_type, threshold=70, cut=False, rotate=0)
# noinspection PyCallingNonCallable
be = BACKEND_CLASS(BACKEND_STRING_DESCR)
be.write(qlr.data)
be.dispose()
del be
except Exception as e:
raise e
def _cut(self):
self.ready = False
try:
selected_backend = guess_backend(self.printer_info.identifier)
BACKEND_CLASS = backend_factory(selected_backend)['backend_class']
BACKEND_STRING_DESCR = self.printer_info.identifier
except Exception as e:
self.app.logger.error("Couldn't guess the backend to use from the printer string descriptor")
data = "src/static/images/1x1.png"
try:
im = Image.open(data)
qlr = BrotherQLRaster(self.printer_info.model)
create_label(qlr, im, self.printer_info.label_type, threshold=70, cut=True, rotate=0)
# noinspection PyCallingNonCallable
be = BACKEND_CLASS(BACKEND_STRING_DESCR)
be.write(qlr.data)
be.dispose()
del be
except Exception as e:
raise e
def print_task(self, task_type, data):
"""Execute actual print based on task type"""
with self._lock:
if self.ready:
try:
match (task_type.value):
case "image":
self._print_img(data["img"])
self.ready = True
case "cut":
self._cut()
self.ready = True
case "text":
self._print_txt(data["txt"], signature=data["sign"])
self.ready = True
case _:
raise RuntimeError("This task type is not supported")
except Exception as e:
raise e
# end try
else:
raise RuntimeError("The printer is not ready to print yet !")
# These values are by default for now
# raise NotImplementedError("This printer type is not implemented yet")
def _process_image(self, path, max_width=575, max_height=1000):
brightness_threshold = 100
contrast_factor = 2.0
with Image.open(path) as img:
# Preserve aspect ratio while fitting within the maximum dimensions.
img.thumbnail(
(max_width, max_height),
Image.Resampling.LANCZOS,
)
grayscale = img.convert("L")
avg_brightness = np.asarray(grayscale, dtype=np.float32).mean()
self.app.logger.debug(
"Average brightness of the image: %.2f", avg_brightness
)
if avg_brightness < brightness_threshold:
brightness_factor = min(
max(
1 + (brightness_threshold - avg_brightness)
/ brightness_threshold,
1.1,
),
2.5,
)
self.app.logger.debug(
"Image is dark; increasing brightness by %.2f",
brightness_factor,
)
grayscale = ImageEnhance.Brightness(grayscale).enhance(
brightness_factor
)
contrast = float(np.asarray(grayscale, dtype=np.float32).std())
self.app.logger.debug("Grayscale standard deviation: %.2f", contrast)
# Save the enhanced result.
processed = ImageEnhance.Contrast(grayscale).enhance(contrast_factor)
jpeg_path = os.path.splitext(path)[0] + "_processed.jpg"
processed.save(jpeg_path, format="JPEG", quality=95, optimize=True)
self.app.logger.debug("Processed and saved image: %s", jpeg_path)
return jpeg_path
def text_to_image(
text,
width=400,
font_path="src/static/font/MonospaceTypewriter.ttf",
font_size=24,
padding=10,
line_spacing=4,
background="white",
fill="black",
output_dir="src/static/uploads",
):
"""Converts a string to an image"""
font = ImageFont.truetype(font_path, font_size)
# Measure and wrap text by pixel width.
measure_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(measure_img)
max_text_width = width - 2 * padding
if max_text_width <= 0:
raise ValueError("width must be greater than twice the padding")
lines = []
for paragraph in text.splitlines() or [""]:
words = paragraph.split()
if not words:
lines.append("")
continue
line = ""
for word in words:
candidate = f"{line} {word}".strip()
if draw.textbbox((0, 0), candidate, font=font)[2] <= max_text_width:
line = candidate
continue
if line:
lines.append(line)
# Split a word that is wider than the available width.
line = ""
piece = ""
for char in word:
if piece and draw.textbbox((0, 0), piece + char, font=font)[2] > max_text_width:
lines.append(piece)
piece = char
else:
piece += char
line = piece
if line:
lines.append(line)
if not lines:
lines = [""]
ascent, descent = font.getmetrics()
line_height = ascent + descent
height = (
2 * padding
+ len(lines) * line_height
+ (len(lines) - 1) * line_spacing
)
image = Image.new("RGB", (width, height), background)
draw = ImageDraw.Draw(image)
y = padding
for line in lines:
draw.text((padding, y), line, font=font, fill=fill)
y += line_height + line_spacing
upload_dir = Path(output_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
image_path = upload_dir / f"{uuid4().hex}.png"
image.save(image_path, format="PNG")
return str(image_path)