diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a67570 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8de057d --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/client.py b/client.py new file mode 100644 index 0000000..89b8f3b --- /dev/null +++ b/client.py @@ -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() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fdb2210 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ebb145 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +paho-mqtt +Pillow +brother-ql \ No newline at end of file diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..9de396e --- /dev/null +++ b/templates.py @@ -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 +} \ No newline at end of file