Compare commits

...

7 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
4 changed files with 236 additions and 26 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

@@ -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)

115
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')
@@ -24,24 +30,21 @@ def print_label(image, printer=PRINTER_DEVICE, model=PRINTER_MODEL, label=LABEL_
"""Print the label directly using brother_ql module""" """Print the label directly using brother_ql module"""
import os import os
try: # Check if printer device exists
# Check if printer device exists if not os.path.exists(printer):
if not os.path.exists(printer): raise Exception(f"Printer device {printer} not found. Make sure the printer is powered on and connected via USB.")
raise Exception(f"Printer device {printer} not found. Make sure the printer is powered on and connected via USB.")
qlr = BrotherQLRaster(model)
qlr = BrotherQLRaster(model) qlr.exception_on_warning = True
qlr.exception_on_warning = True
# Convert the PIL image to instructions
# Convert the PIL image to instructions instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
# Send to printer using linux_kernel backend
# Send to printer using linux_kernel backend print(f"Sending to printer: {printer}")
print(f"Sending to printer: {printer}") status = send(instructions=instructions, printer_identifier=f"file://{printer}", backend_identifier='linux_kernel', blocking=True)
status = send(instructions=instructions, printer_identifier=f"file://{printer}", backend_identifier='linux_kernel', blocking=True)
return status
return status
except Exception as e:
raise e
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}")
@@ -60,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', {})
@@ -85,6 +90,17 @@ 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)
@@ -99,6 +115,8 @@ def on_message(client, userdata, msg):
errors = printer_state.get('errors', []) errors = printer_state.get('errors', [])
error_details = { error_details = {
"status": "error",
"job_id": job_id,
"error": f"Print failed: {status_type}", "error": f"Print failed: {status_type}",
"status_type": status_type, "status_type": status_type,
"media_type": media_type, "media_type": media_type,
@@ -110,17 +128,30 @@ def on_message(client, userdata, msg):
error_msg = f"Print failed: {status_type}. Media: {media_type} ({media_width}mm)" error_msg = f"Print failed: {status_type}. Media: {media_type} ({media_width}mm)"
print(error_msg) print(error_msg)
client.publish(MQTT_TOPIC_PUB_ERRORS, json.dumps(error_details)) client.publish(MQTT_TOPIC_PUB_STATUS, json.dumps(error_details))
raise Exception(error_msg) 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:
@@ -129,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

@@ -148,9 +148,153 @@ def new_label_template(variables, width_pixels=991, height_pixels=306, filename=
return image 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': 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
} }