Initial Version PoC
This commit is contained in:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Output files
|
||||||
|
output/
|
||||||
|
*.png
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
# Disable Python output buffering
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
fonts-dejavu \
|
||||||
|
usbutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the application
|
||||||
|
COPY client.py .
|
||||||
|
COPY templates.py .
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "client.py"]
|
||||||
137
client.py
Normal file
137
client.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from datetime import date
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
from templates import TEMPLATES
|
||||||
|
|
||||||
|
# Load configuration from environment
|
||||||
|
MQTT_HOST = os.getenv('MQTT_HOST', 'localhost')
|
||||||
|
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
|
||||||
|
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_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')
|
||||||
|
LABEL_SIZE_DEFAULT = os.getenv('LABEL_SIZE_DEFAULT', '29x90')
|
||||||
|
|
||||||
|
def print_label(image, printer=PRINTER_DEVICE, model=PRINTER_MODEL, label=LABEL_SIZE_DEFAULT):
|
||||||
|
"""Print the label directly using brother_ql module"""
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
status = send(instructions=instructions, printer_identifier=printer, backend_identifier='linux_kernel', blocking=True)
|
||||||
|
|
||||||
|
return status
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, rc):
|
||||||
|
print(f"Connected to MQTT broker at {MQTT_HOST}:{MQTT_PORT} with result code {rc}")
|
||||||
|
if rc == 0:
|
||||||
|
print(f"Subscribing to topic: {MQTT_TOPIC_SUB}")
|
||||||
|
client.subscribe(MQTT_TOPIC_SUB)
|
||||||
|
print("Subscription successful")
|
||||||
|
else:
|
||||||
|
print(f"Failed to connect, result code: {rc}")
|
||||||
|
|
||||||
|
def on_message(client, userdata, msg):
|
||||||
|
try:
|
||||||
|
raw_payload = msg.payload.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
print(f"Received non-UTF-8 message on topic '{msg.topic}': {msg.payload}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Raw message received on topic '{msg.topic}': {raw_payload}")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_payload)
|
||||||
|
print(f"Parsed payload: {payload}")
|
||||||
|
|
||||||
|
template_id = payload.get('template_id', 'vet_label')
|
||||||
|
label_size = payload.get('label_size', LABEL_SIZE_DEFAULT)
|
||||||
|
variables = payload.get('variables', {})
|
||||||
|
test = payload.get('test', False)
|
||||||
|
|
||||||
|
print(f"Processing: template_id={template_id}, label_size={label_size}, test={test}")
|
||||||
|
|
||||||
|
# For now, only support one template
|
||||||
|
if template_id not in TEMPLATES:
|
||||||
|
raise ValueError(f"Unknown template_id: {template_id}")
|
||||||
|
|
||||||
|
template_func = TEMPLATES[template_id]
|
||||||
|
|
||||||
|
# TODO: Adjust dimensions based on label_size if needed
|
||||||
|
# For simplicity, using fixed dimensions
|
||||||
|
|
||||||
|
filename = f"/app/output/label_{template_id}.png" if test else None
|
||||||
|
image = template_func(variables, filename=filename)
|
||||||
|
|
||||||
|
if test:
|
||||||
|
print(f"Test mode: PNG saved as {filename}")
|
||||||
|
else:
|
||||||
|
print("Printing label...")
|
||||||
|
status = print_label(image, label=label_size)
|
||||||
|
print(f"Print status: {status}")
|
||||||
|
|
||||||
|
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}))
|
||||||
|
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}))
|
||||||
|
|
||||||
|
def heartbeat(client):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
heartbeat_msg = {
|
||||||
|
"status": "alive",
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"host": MQTT_HOST,
|
||||||
|
"port": MQTT_PORT
|
||||||
|
}
|
||||||
|
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.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
print(f"Attempting to connect to MQTT broker at {MQTT_HOST}:{MQTT_PORT}")
|
||||||
|
client.connect(MQTT_HOST, MQTT_PORT, 60)
|
||||||
|
|
||||||
|
# Start the network loop in a background thread
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
# Start heartbeat thread
|
||||||
|
heartbeat_thread = threading.Thread(target=heartbeat, args=(client,))
|
||||||
|
heartbeat_thread.daemon = True
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
print("Client is running. Press Ctrl+C to stop.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1) # Keep main thread alive
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Shutting down...")
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
vet-label-client:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
devices:
|
||||||
|
- /dev/usb/lp0:/dev/usb/lp0
|
||||||
|
volumes:
|
||||||
|
- ./output:/app/output # For test PNGs
|
||||||
|
restart: unless-stopped
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
paho-mqtt
|
||||||
|
Pillow
|
||||||
|
brother-ql
|
||||||
76
templates.py
Normal file
76
templates.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Template definitions for different label layouts
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
def vet_label_template(variables, width_pixels=991, height_pixels=306, filename=None):
|
||||||
|
"""Default vet label template"""
|
||||||
|
image = Image.new('RGB', (width_pixels, height_pixels), 'white')
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_bold = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36)
|
||||||
|
font_normal = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 32)
|
||||||
|
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_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 = 750
|
||||||
|
line_height = 50
|
||||||
|
|
||||||
|
# Practice name (top left)
|
||||||
|
draw.text((left_col, top), variables.get('practice_name', ''), fill='black', font=font_bold)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Three columns of data
|
||||||
|
y = top + line_height + 10
|
||||||
|
|
||||||
|
# Column 1
|
||||||
|
draw.text((left_col, y), "Animal:", fill='black', font=font_normal)
|
||||||
|
draw.text((left_col, y + line_height), "Drug:", fill='black', font=font_normal)
|
||||||
|
draw.text((left_col, y + 2 * line_height), "Dose:", fill='black', font=font_normal)
|
||||||
|
|
||||||
|
# Column 2
|
||||||
|
draw.text((x1, y), variables.get('animal_name', ''), fill='black', font=font_normal)
|
||||||
|
draw.text((x1, y + line_height), variables.get('drug_name', ''), fill='black', font=font_normal)
|
||||||
|
draw.text((x1, y + 2 * line_height), variables.get('dosage', ''), fill='black', font=font_normal)
|
||||||
|
|
||||||
|
# Column 3
|
||||||
|
draw.text((x2, y), "Qty:", fill='black', font=font_normal)
|
||||||
|
draw.text((x2, y + line_height), "Vet:", fill='black', font=font_normal)
|
||||||
|
|
||||||
|
draw.text((x3, y), variables.get('quantity', ''), fill='black', font=font_normal)
|
||||||
|
draw.text((x3, y + line_height), variables.get('vet_initials', ''), 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
|
||||||
|
|
||||||
|
# Template registry
|
||||||
|
TEMPLATES = {
|
||||||
|
'vet_label': vet_label_template,
|
||||||
|
# Add more templates here, e.g., 'vet_label_small': vet_label_small_template
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user