Compare commits

..

9 Commits

Author SHA1 Message Date
ba4270e32e Update gitignore 2026-02-18 06:14:49 -05:00
9bcce9564d New template fix 2026-02-18 06:14:16 -05:00
39c2fbc6bd New Notes template 2026-02-18 05:18:25 -05:00
6a41765603 MQTT status fix 2026-02-12 08:14:45 +00:00
ae622aa5bd Websockets MQTT support 2026-02-09 20:23:11 +00:00
af7c4ade1d remove heartbeat from logs 2026-02-07 14:54:24 +00:00
652c433509 Print job status reporting 2026-02-07 14:39:06 +00:00
b412900b4b Robustness fixes 2026-02-06 17:35:31 +00:00
0fab91c077 new template 2026-02-06 16:58:41 +00:00
6 changed files with 345 additions and 27 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
SCRATCH
# Virtual environments # Virtual environments
venv/ venv/

View File

@@ -6,7 +6,6 @@ ENV PYTHONUNBUFFERED=1
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
fonts-dejavu \ fonts-dejavu \
usbutils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Set working directory # Set working directory
@@ -16,9 +15,6 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy the application # Application files will be mounted via volume
COPY client.py .
COPY templates.py .
# Run the application # Run the application
CMD ["python", "client.py"] CMD ["python", "client.py"]

View File

@@ -8,7 +8,7 @@ This is a Docker-based MQTT client that listens for print requests, generates ve
- `MQTT_HOST`: MQTT broker hostname - `MQTT_HOST`: MQTT broker hostname
- `MQTT_PORT`: MQTT broker port - `MQTT_PORT`: MQTT broker port
- `MQTT_TOPIC_SUB`: Topic to subscribe for print requests - `MQTT_TOPIC_SUB`: Topic to subscribe for print requests
- `MQTT_TOPIC_PUB_ERRORS`: Topic to publish errors - `MQTT_TOPIC_PUB_STATUS`: Topic to publish status messages (success and errors)
- `PRINTER_DEVICE`: Printer device path (e.g., /dev/usb/lp0) - `PRINTER_DEVICE`: Printer device path (e.g., /dev/usb/lp0)
- `PRINTER_MODEL`: Printer model (e.g., QL-800) - `PRINTER_MODEL`: Printer model (e.g., QL-800)
- `LABEL_SIZE_DEFAULT`: Default label size (e.g., 29x90) - `LABEL_SIZE_DEFAULT`: Default label size (e.g., 29x90)

133
client.py
View File

@@ -2,6 +2,7 @@ import os
import json import json
import time import time
import threading import threading
import ssl
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from datetime import date from datetime import date
@@ -13,8 +14,13 @@ from templates import TEMPLATES
# Load configuration from environment # Load configuration from environment
MQTT_HOST = os.getenv('MQTT_HOST', 'localhost') MQTT_HOST = os.getenv('MQTT_HOST', 'localhost')
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883)) MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
MQTT_USE_WEBSOCKET = os.getenv('MQTT_USE_WEBSOCKET', 'False').lower() == 'true'
MQTT_WEBSOCKET_PATH = os.getenv('MQTT_WEBSOCKET_PATH', '/mqtt')
MQTT_TLS_INSECURE = os.getenv('MQTT_TLS_INSECURE', 'False').lower() == 'true'
MQTT_USERNAME = os.getenv('MQTT_USERNAME', '')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', '')
MQTT_TOPIC_SUB = os.getenv('MQTT_TOPIC_SUB', 'vet/labels/print') MQTT_TOPIC_SUB = os.getenv('MQTT_TOPIC_SUB', 'vet/labels/print')
MQTT_TOPIC_PUB_ERRORS = os.getenv('MQTT_TOPIC_PUB_ERRORS', 'vet/labels/errors') MQTT_TOPIC_PUB_STATUS = os.getenv('MQTT_TOPIC_PUB_STATUS', 'vet/labels/status')
MQTT_TOPIC_HEARTBEAT = os.getenv('MQTT_TOPIC_HEARTBEAT', 'vet/labels/heartbeat') MQTT_TOPIC_HEARTBEAT = os.getenv('MQTT_TOPIC_HEARTBEAT', 'vet/labels/heartbeat')
PRINTER_DEVICE = os.getenv('PRINTER_DEVICE', '/dev/usb/lp0') PRINTER_DEVICE = os.getenv('PRINTER_DEVICE', '/dev/usb/lp0')
PRINTER_MODEL = os.getenv('PRINTER_MODEL', 'QL-800') PRINTER_MODEL = os.getenv('PRINTER_MODEL', 'QL-800')
@@ -22,19 +28,23 @@ LABEL_SIZE_DEFAULT = os.getenv('LABEL_SIZE_DEFAULT', '29x90')
def print_label(image, printer=PRINTER_DEVICE, model=PRINTER_MODEL, label=LABEL_SIZE_DEFAULT): def print_label(image, printer=PRINTER_DEVICE, model=PRINTER_MODEL, label=LABEL_SIZE_DEFAULT):
"""Print the label directly using brother_ql module""" """Print the label directly using brother_ql module"""
try: import os
qlr = BrotherQLRaster(model)
qlr.exception_on_warning = True # Check if printer device exists
if not os.path.exists(printer):
# Convert the PIL image to instructions raise Exception(f"Printer device {printer} not found. Make sure the printer is powered on and connected via USB.")
instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
qlr = BrotherQLRaster(model)
# Send to printer qlr.exception_on_warning = True
status = send(instructions=instructions, printer_identifier=printer, backend_identifier='linux_kernel', blocking=True)
# Convert the PIL image to instructions
return status instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
except Exception as e:
raise e # Send to printer using linux_kernel backend
print(f"Sending to printer: {printer}")
status = send(instructions=instructions, printer_identifier=f"file://{printer}", backend_identifier='linux_kernel', blocking=True)
return status
def on_connect(client, userdata, flags, rc): def on_connect(client, userdata, flags, rc):
print(f"Connected to MQTT broker at {MQTT_HOST}:{MQTT_PORT} with result code {rc}") print(f"Connected to MQTT broker at {MQTT_HOST}:{MQTT_PORT} with result code {rc}")
@@ -53,10 +63,12 @@ def on_message(client, userdata, msg):
return return
print(f"Raw message received on topic '{msg.topic}': {raw_payload}") print(f"Raw message received on topic '{msg.topic}': {raw_payload}")
job_id = None
try: try:
payload = json.loads(raw_payload) payload = json.loads(raw_payload)
print(f"Parsed payload: {payload}") print(f"Parsed payload: {payload}")
job_id = payload.get('job_id')
template_id = payload.get('template_id', 'vet_label') template_id = payload.get('template_id', 'vet_label')
label_size = payload.get('label_size', LABEL_SIZE_DEFAULT) label_size = payload.get('label_size', LABEL_SIZE_DEFAULT)
variables = payload.get('variables', {}) variables = payload.get('variables', {})
@@ -78,19 +90,68 @@ def on_message(client, userdata, msg):
if test: if test:
print(f"Test mode: PNG saved as {filename}") print(f"Test mode: PNG saved as {filename}")
# Publish success status for test mode
success_details = {
"status": "success",
"job_id": job_id,
"template_id": template_id,
"label_size": label_size,
"test_mode": True,
"filename": filename,
"timestamp": time.time()
}
client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(success_details))
else: else:
print("Printing label...") print("Printing label...")
status = print_label(image, label=label_size) status = print_label(image, label=label_size)
print(f"Print status: {status}") print(f"Print status: {status}")
# Check for print errors
if not status.get('did_print', False):
printer_state = status.get('printer_state', {})
status_type = printer_state.get('status_type', 'Unknown')
media_type = printer_state.get('media_type', 'Unknown')
media_width = printer_state.get('media_width', 'Unknown')
errors = printer_state.get('errors', [])
error_details = {
"status": "error",
"job_id": job_id,
"error": f"Print failed: {status_type}",
"status_type": status_type,
"media_type": media_type,
"media_width": media_width,
"errors": errors,
"outcome": status.get('outcome', 'unknown'),
"original_payload": raw_payload
}
error_msg = f"Print failed: {status_type}. Media: {media_type} ({media_width}mm)"
print(error_msg)
client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(error_details))
raise Exception(error_msg)
else:
# Print successful - publish success status
success_details = {
"status": "success",
"job_id": job_id,
"template_id": template_id,
"label_size": label_size,
"timestamp": time.time()
}
print(f"Print successful: {template_id}")
client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(success_details))
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
error_msg = f"Invalid JSON in message: {e}. Raw payload: {raw_payload}" error_msg = f"Invalid JSON in message: {e}. Raw payload: {raw_payload}"
print(error_msg) print(error_msg)
client.publish(MQTT_TOPIC_PUB_ERRORS, json.dumps({"error": error_msg, "topic": msg.topic})) error_details = {"status": "error", "job_id": job_id, "error": error_msg, "topic": msg.topic}
client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(error_details))
except Exception as e: except Exception as e:
error_msg = f"Error processing message: {str(e)}" error_msg = f"Error processing message: {str(e)}"
print(error_msg) print(error_msg)
client.publish(MQTT_TOPIC_PUB_ERRORS, json.dumps({"error": error_msg, "original_payload": raw_payload})) error_details = {"status": "error", "job_id": job_id, "error": error_msg, "original_payload": raw_payload}
client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(error_details))
def heartbeat(client): def heartbeat(client):
while True: while True:
@@ -99,21 +160,55 @@ def heartbeat(client):
"status": "alive", "status": "alive",
"timestamp": time.time(), "timestamp": time.time(),
"host": MQTT_HOST, "host": MQTT_HOST,
"port": MQTT_PORT "port": MQTT_PORT,
"websocket": MQTT_USE_WEBSOCKET
} }
client.publish(MQTT_TOPIC_HEARTBEAT, json.dumps(heartbeat_msg)) client.publish(MQTT_TOPIC_HEARTBEAT, json.dumps(heartbeat_msg))
print(f"Published heartbeat: {heartbeat_msg}")
time.sleep(30) # Publish every 30 seconds time.sleep(30) # Publish every 30 seconds
except Exception as e: except Exception as e:
print(f"Error publishing heartbeat: {e}") print(f"Error publishing heartbeat: {e}")
time.sleep(5) # Retry sooner on error time.sleep(5) # Retry sooner on error
def main(): def main():
client = mqtt.Client() client = mqtt.Client(client_id="printer_client", transport="websockets" if MQTT_USE_WEBSOCKET else "tcp")
client.on_connect = on_connect client.on_connect = on_connect
client.on_message = on_message client.on_message = on_message
# Configure TLS if using WebSocket
if MQTT_USE_WEBSOCKET:
if MQTT_TLS_INSECURE:
# Disable TLS certificate verification (use with caution)
print("WARNING: TLS certificate verification is disabled!")
client.tls_set(
ca_certs=None,
certfile=None,
keyfile=None,
cert_reqs=ssl.CERT_NONE,
tls_version=ssl.PROTOCOL_TLS,
ciphers=None
)
client.tls_insecure_set(True)
else:
# Use default CA certificates
client.tls_set(
ca_certs=None,
certfile=None,
keyfile=None,
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLS,
ciphers=None
)
# Set username and password if provided
if MQTT_USERNAME or MQTT_PASSWORD:
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# Construct connection string with WebSocket path if needed
print(f"Attempting to connect to MQTT broker at {MQTT_HOST}:{MQTT_PORT}") print(f"Attempting to connect to MQTT broker at {MQTT_HOST}:{MQTT_PORT}")
if MQTT_USE_WEBSOCKET:
print(f"Using WebSocket transport with path: {MQTT_WEBSOCKET_PATH}")
print(f"TLS Certificate Validation: {'Disabled' if MQTT_TLS_INSECURE else 'Enabled'}")
client.connect(MQTT_HOST, MQTT_PORT, 60) client.connect(MQTT_HOST, MQTT_PORT, 60)
# Start the network loop in a background thread # Start the network loop in a background thread

View File

@@ -5,8 +5,10 @@ services:
build: . build: .
env_file: env_file:
- .env - .env
devices: privileged: true # Required for /dev access when printer powers on/off
- /dev/usb/lp0:/dev/usb/lp0
volumes: volumes:
- ./output:/app/output # For test PNGs - ./output:/app/output # For test PNGs
- ./client.py:/app/client.py # Mount source for live updates
- ./templates.py:/app/templates.py # Mount templates for live updates
- /dev:/dev # Mount /dev for dynamic device access
restart: unless-stopped restart: unless-stopped

View File

@@ -69,8 +69,232 @@ def vet_label_template(variables, width_pixels=991, height_pixels=306, filename=
return image return image
def new_label_template(variables, width_pixels=991, height_pixels=306, filename=None):
"""Simplified label template with centered drug and dose"""
image = Image.new('RGB', (width_pixels, height_pixels), 'white')
draw = ImageDraw.Draw(image)
try:
font_header = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36)
font_bold = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 48)
font_normal = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28)
font_footer = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf', 22)
except:
font_header = ImageFont.load_default()
font_bold = ImageFont.load_default()
font_normal = ImageFont.load_default()
font_small = ImageFont.load_default()
font_footer = ImageFont.load_default()
margin = 10
top = margin
left_col = margin
x1 = 200
x2 = 500
x3 = 580
line_height = 50
# Practice name (top left)
draw.text((left_col, top), variables.get('practice_name', ''), fill='black', font=font_header)
# Date (top right)
today = date.today().strftime("%d %b %Y")
bbox = draw.textbbox((0, 0), today, font=font_small)
date_width = bbox[2] - bbox[0]
draw.text((width_pixels - margin - date_width, top), today, fill='black', font=font_small)
# Centered drug name (bold)
y = top + line_height + 10
drug_name = variables.get('drug_name', '')
bbox = draw.textbbox((0, 0), drug_name, font=font_bold)
drug_width = bbox[2] - bbox[0]
drug_x = (width_pixels - drug_width) / 2
drug_y = y
draw.text((drug_x, drug_y), drug_name, fill='black', font=font_bold)
# Centered dose underneath
dosage = variables.get('dosage', '')
bbox = draw.textbbox((0, 0), dosage, font=font_normal)
dose_width = bbox[2] - bbox[0]
dose_x = (width_pixels - dose_width) / 2
dose_y = drug_y + 60
draw.text((dose_x, dose_y), dosage, fill='black', font=font_normal)
# Animal and Qty on same line below drug/dose
animal_qty_y = dose_y + 50
draw.text((left_col, animal_qty_y), "Animal:", fill='black', font=font_normal)
draw.text((x1, animal_qty_y), variables.get('animal_name', ''), fill='black', font=font_normal)
draw.text((x2, animal_qty_y), "Qty:", fill='black', font=font_normal)
draw.text((x3, animal_qty_y), variables.get('quantity', ''), fill='black', font=font_normal)
# Expiry and Vet on next line
expiry_vet_y = animal_qty_y + line_height
draw.text((left_col, expiry_vet_y), "Expiry:", fill='black', font=font_normal)
draw.text((x1, expiry_vet_y), variables.get('expiry_date', ''), fill='black', font=font_normal)
draw.text((x2, expiry_vet_y), "Vet:", fill='black', font=font_normal)
draw.text((x3, expiry_vet_y), "________________________", fill='black', font=font_normal)
# Footer at bottom
footer_text = "For animal treatment only"
bbox = draw.textbbox((0, 0), footer_text, font=font_footer)
text_width = bbox[2] - bbox[0]
footer_x = (width_pixels - text_width) / 2
footer_y = height_pixels - margin - 20
draw.text((footer_x, footer_y), footer_text, fill='black', font=font_footer)
if filename:
image.save(filename)
return image
def new_label_large_template(variables, width_pixels=991, height_pixels=413, filename=None):
"""Simplified label template with centered drug and dose - larger format"""
image = Image.new('RGB', (width_pixels, height_pixels), 'white')
draw = ImageDraw.Draw(image)
try:
font_header = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36)
font_bold = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 48)
font_normal = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28)
font_footer = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf', 22)
except:
font_header = ImageFont.load_default()
font_bold = ImageFont.load_default()
font_normal = ImageFont.load_default()
font_small = ImageFont.load_default()
font_footer = ImageFont.load_default()
margin = 10
top = margin
left_col = margin
x1 = 200
x2 = 500
x3 = 580
line_height = 60
# Practice name (top left)
draw.text((left_col, top), variables.get('practice_name', ''), fill='black', font=font_header)
# Date (top right)
today = date.today().strftime("%d %b %Y")
bbox = draw.textbbox((0, 0), today, font=font_small)
date_width = bbox[2] - bbox[0]
draw.text((width_pixels - margin - date_width, top), today, fill='black', font=font_small)
# Centered drug name (bold)
y = top + line_height + 20
drug_name = variables.get('drug_name', '')
bbox = draw.textbbox((0, 0), drug_name, font=font_bold)
drug_width = bbox[2] - bbox[0]
drug_x = (width_pixels - drug_width) / 2
drug_y = y
draw.text((drug_x, drug_y), drug_name, fill='black', font=font_bold)
# Centered dose underneath
dosage = variables.get('dosage', '')
bbox = draw.textbbox((0, 0), dosage, font=font_normal)
dose_width = bbox[2] - bbox[0]
dose_x = (width_pixels - dose_width) / 2
dose_y = drug_y + 70
draw.text((dose_x, dose_y), dosage, fill='black', font=font_normal)
# Animal and Qty on same line below drug/dose
animal_qty_y = dose_y + 70
draw.text((left_col, animal_qty_y), "Animal:", fill='black', font=font_normal)
draw.text((x1, animal_qty_y), variables.get('animal_name', ''), fill='black', font=font_normal)
draw.text((x2, animal_qty_y), "Qty:", fill='black', font=font_normal)
draw.text((x3, animal_qty_y), variables.get('quantity', ''), fill='black', font=font_normal)
# Expiry and Vet on next line
expiry_vet_y = animal_qty_y + line_height
draw.text((left_col, expiry_vet_y), "Expiry:", fill='black', font=font_normal)
draw.text((x1, expiry_vet_y), variables.get('expiry_date', ''), fill='black', font=font_normal)
draw.text((x2, expiry_vet_y), "Vet:", fill='black', font=font_normal)
draw.text((x3, expiry_vet_y), "________________________", fill='black', font=font_normal)
# Footer at bottom
footer_text = "For animal treatment only"
bbox = draw.textbbox((0, 0), footer_text, font=font_footer)
text_width = bbox[2] - bbox[0]
footer_x = (width_pixels - text_width) / 2
footer_y = height_pixels - margin - 20
draw.text((footer_x, footer_y), footer_text, fill='black', font=font_footer)
if filename:
image.save(filename)
return image
def notes_1_template(variables, width_pixels=991, height_pixels=413, filename=None):
"""Notes template with animal/date header and wrapped notes"""
image = Image.new('RGB', (width_pixels, height_pixels), 'white')
draw = ImageDraw.Draw(image)
try:
font_header = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36)
font_notes = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 32)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28)
except:
font_header = ImageFont.load_default()
font_notes = ImageFont.load_default()
font_small = ImageFont.load_default()
margin = 10
top = margin
left_col = margin
right_margin = margin
line_height = 46
animal = variables.get('animal_name', '')
note_date = date.today().strftime("%d %b %Y")
notes = variables.get('notes', '')
# Animal (left) and date (right) on the same line
draw.text((left_col, top), animal, fill='black', font=font_header)
bbox = draw.textbbox((0, 0), note_date, font=font_small)
date_width = bbox[2] - bbox[0]
draw.text((width_pixels - right_margin - date_width, top + 4), note_date, fill='black', font=font_small)
# Wrapped notes below the header
notes_top = top + line_height + 10
max_width = width_pixels - (left_col + right_margin)
def wrap_text(text, font, max_px):
lines = []
for raw_line in text.splitlines():
words = raw_line.split()
if not words:
lines.append('')
continue
line = []
for word in words:
test_line = ' '.join(line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
test_width = bbox[2] - bbox[0]
if test_width <= max_px or not line:
line.append(word)
else:
lines.append(' '.join(line))
line = [word]
if line:
lines.append(' '.join(line))
return lines
for i, line in enumerate(wrap_text(notes, font_notes, max_width)):
draw.text((left_col, notes_top + i * (line_height - 6)), line, fill='black', font=font_notes)
if filename:
image.save(filename)
return image
# Template registry # Template registry
TEMPLATES = { TEMPLATES = {
'vet_label': vet_label_template, 'vet_label': vet_label_template,
'new_label': new_label_template,
'new_label_large': new_label_large_template,
'notes_1': notes_1_template,
# Add more templates here, e.g., 'vet_label_small': vet_label_small_template # Add more templates here, e.g., 'vet_label_small': vet_label_small_template
} }