Compare commits
7 Commits
b412900b4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba4270e32e | |||
| 9bcce9564d | |||
| 39c2fbc6bd | |||
| 6a41765603 | |||
| ae622aa5bd | |||
| af7c4ade1d | |||
| 652c433509 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
SCRATCH
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
|
||||
@@ -8,7 +8,7 @@ This is a Docker-based MQTT client that listens for print requests, generates ve
|
||||
- `MQTT_HOST`: MQTT broker hostname
|
||||
- `MQTT_PORT`: MQTT broker port
|
||||
- `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_MODEL`: Printer model (e.g., QL-800)
|
||||
- `LABEL_SIZE_DEFAULT`: Default label size (e.g., 29x90)
|
||||
|
||||
115
client.py
115
client.py
@@ -2,6 +2,7 @@ import os
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import ssl
|
||||
import paho.mqtt.client as mqtt
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from datetime import date
|
||||
@@ -13,8 +14,13 @@ from templates import TEMPLATES
|
||||
# Load configuration from environment
|
||||
MQTT_HOST = os.getenv('MQTT_HOST', 'localhost')
|
||||
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_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')
|
||||
PRINTER_DEVICE = os.getenv('PRINTER_DEVICE', '/dev/usb/lp0')
|
||||
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"""
|
||||
import os
|
||||
|
||||
try:
|
||||
# Check if printer device exists
|
||||
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.")
|
||||
|
||||
qlr = BrotherQLRaster(model)
|
||||
qlr.exception_on_warning = True
|
||||
|
||||
# Convert the PIL image to instructions
|
||||
instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
|
||||
|
||||
# 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
|
||||
except Exception as e:
|
||||
raise e
|
||||
# Check if printer device exists
|
||||
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.")
|
||||
|
||||
qlr = BrotherQLRaster(model)
|
||||
qlr.exception_on_warning = True
|
||||
|
||||
# Convert the PIL image to instructions
|
||||
instructions = convert(qlr=qlr, images=[image], label=label, cut=True)
|
||||
|
||||
# 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):
|
||||
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
|
||||
|
||||
print(f"Raw message received on topic '{msg.topic}': {raw_payload}")
|
||||
job_id = None
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
print(f"Parsed payload: {payload}")
|
||||
|
||||
job_id = payload.get('job_id')
|
||||
template_id = payload.get('template_id', 'vet_label')
|
||||
label_size = payload.get('label_size', LABEL_SIZE_DEFAULT)
|
||||
variables = payload.get('variables', {})
|
||||
@@ -85,6 +90,17 @@ def on_message(client, userdata, msg):
|
||||
|
||||
if test:
|
||||
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:
|
||||
print("Printing label...")
|
||||
status = print_label(image, label=label_size)
|
||||
@@ -99,6 +115,8 @@ def on_message(client, userdata, msg):
|
||||
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,
|
||||
@@ -110,17 +128,30 @@ def on_message(client, userdata, msg):
|
||||
|
||||
error_msg = f"Print failed: {status_type}. Media: {media_type} ({media_width}mm)"
|
||||
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)
|
||||
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:
|
||||
error_msg = f"Invalid JSON in message: {e}. Raw payload: {raw_payload}"
|
||||
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:
|
||||
error_msg = f"Error processing message: {str(e)}"
|
||||
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):
|
||||
while True:
|
||||
@@ -129,21 +160,55 @@ def heartbeat(client):
|
||||
"status": "alive",
|
||||
"timestamp": time.time(),
|
||||
"host": MQTT_HOST,
|
||||
"port": MQTT_PORT
|
||||
"port": MQTT_PORT,
|
||||
"websocket": MQTT_USE_WEBSOCKET
|
||||
}
|
||||
client.publish(MQTT_TOPIC_HEARTBEAT, json.dumps(heartbeat_msg))
|
||||
print(f"Published heartbeat: {heartbeat_msg}")
|
||||
time.sleep(30) # Publish every 30 seconds
|
||||
except Exception as e:
|
||||
print(f"Error publishing heartbeat: {e}")
|
||||
time.sleep(5) # Retry sooner on error
|
||||
|
||||
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_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}")
|
||||
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)
|
||||
|
||||
# Start the network loop in a background thread
|
||||
|
||||
144
templates.py
144
templates.py
@@ -148,9 +148,153 @@ def new_label_template(variables, width_pixels=991, height_pixels=306, 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
|
||||
TEMPLATES = {
|
||||
'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
|
||||
}
|
||||
Reference in New Issue
Block a user