From 4010ffc01b2d9bcba77fcdfd3853689776037655 Mon Sep 17 00:00:00 2001 From: timmyhadwen Date: Sun, 21 Dec 2025 16:46:14 +1000 Subject: [PATCH] Initial commit: Brother QL USB-to-Network Bridge - TCP server exposing USB Brother QL printers on port 9100 - Supports all Brother QL series label printers - Async architecture for handling concurrent print jobs - Raspberry Pi deployment script and systemd service - Documentation for setup and InvenTree integration --- .gitignore | 20 ++++ README.md | 183 ++++++++++++++++++++++++++++++++ bridge.py | 261 ++++++++++++++++++++++++++++++++++++++++++++++ config.py | 15 +++ pyproject.toml | 9 ++ requirements.txt | 2 + scripts/deploy.sh | 95 +++++++++++++++++ 7 files changed, 585 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bridge.py create mode 100644 config.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 scripts/deploy.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f03547 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +*.egg-info/ + +# uv +uv.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..52e8471 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Brother QL Bridge + +A TCP-to-USB bridge that exposes a USB-connected Brother QL label printer on the network, making it compatible with network-based printing tools like InvenTree's Brother plugin. + +## What It Does + +- Listens on TCP port 9100 (standard raw printing port) +- Receives print jobs from network clients +- Forwards the raw Brother raster data to a USB-connected QL printer + +## Supported Printers + +- QL-500, QL-550, QL-560, QL-570, QL-580N +- QL-650TD, QL-700, QL-710W, QL-720NW +- QL-800, QL-810W, QL-820NWB +- QL-1050, QL-1060N + +## Raspberry Pi Deployment + +### Prerequisites + +1. Raspberry Pi with Raspberry Pi OS (Bookworm or later recommended) +2. Brother QL printer connected via USB +3. Python 3.10+ + +### Step 1: Install System Dependencies + +```bash +sudo apt update +sudo apt install -y python3 python3-pip python3-venv libusb-1.0-0-dev +``` + +### Step 2: Clone/Copy the Project + +```bash +# Create project directory +mkdir -p ~/brother-ql-bridge +cd ~/brother-ql-bridge + +# Copy files: bridge.py, config.py, pyproject.toml, requirements.txt +``` + +### Step 3: Setup Python Environment + +Using `uv` (recommended): +```bash +# Install uv if not already installed +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Create venv and install dependencies +cd ~/brother-ql-bridge +uv venv +uv pip install -r requirements.txt +``` + +Or using standard pip: +```bash +cd ~/brother-ql-bridge +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### Step 4: Configure USB Permissions + +Create a udev rule to allow non-root access to Brother printers: + +```bash +sudo tee /etc/udev/rules.d/99-brother-ql.rules << 'EOF' +# Brother QL label printers +SUBSYSTEM=="usb", ATTR{idVendor}=="04f9", MODE="0666" +EOF + +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +### Step 5: Test the Bridge + +```bash +cd ~/brother-ql-bridge +source .venv/bin/activate +python bridge.py -v +``` + +You should see output like: +``` +2024-12-21 16:30:00 [INFO] Found printer: QL-570 +2024-12-21 16:30:00 [INFO] USB connection established +2024-12-21 16:30:00 [INFO] Bridge server listening on ('0.0.0.0', 9100) +``` + +### Step 6: Install as Systemd Service + +Create the service file: + +```bash +sudo tee /etc/systemd/system/brother-ql-bridge.service << EOF +[Unit] +Description=Brother QL USB-to-Network Bridge +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$HOME/brother-ql-bridge +ExecStart=$HOME/brother-ql-bridge/.venv/bin/python bridge.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable brother-ql-bridge +sudo systemctl start brother-ql-bridge +``` + +Check status: + +```bash +sudo systemctl status brother-ql-bridge +sudo journalctl -u brother-ql-bridge -f # Follow logs +``` + +## Configuration + +Edit `config.py` to customize: + +| Setting | Default | Description | +|---------|---------|-------------| +| `TCP_HOST` | `0.0.0.0` | Listen address (0.0.0.0 = all interfaces) | +| `TCP_PORT` | `9100` | TCP port for incoming print jobs | +| `USB_DEVICE` | `None` | USB device path (None = auto-detect) | +| `PRINTER_MODEL` | `QL-570` | Printer model for compatibility | +| `LOG_LEVEL` | `INFO` | Logging verbosity | + +## Command Line Options + +``` +python bridge.py [-h] [-p PORT] [-H HOST] [-d DEVICE] [-v] + +Options: + -p, --port PORT TCP port (default: 9100) + -H, --host HOST Bind address (default: 0.0.0.0) + -d, --device DEVICE USB device (e.g., usb://0x04f9:0x2028) + -v, --verbose Enable debug logging +``` + +## Using with InvenTree + +Configure InvenTree's Brother label plugin with: +- **IP Address**: Your Raspberry Pi's IP address +- **Port**: 9100 (or your configured port) +- **Model**: Your printer model (e.g., QL-570) + +## Troubleshooting + +### "No Brother QL printer found on USB" + +1. Check USB connection: `lsusb | grep Brother` +2. Verify udev rules are loaded: `sudo udevadm control --reload-rules` +3. Unplug and replug the printer + +### Permission denied errors + +Ensure udev rules are in place and you've re-plugged the printer after adding them. + +### Service won't start + +Check logs: `sudo journalctl -u brother-ql-bridge -n 50` + +### Port 9100 in use + +Either change the port in config.py or stop the conflicting service: +```bash +sudo lsof -i :9100 +``` diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..01d8651 --- /dev/null +++ b/bridge.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Brother QL Bridge - USB to Network Bridge + +Exposes a USB-connected Brother QL printer (e.g., QL-570) on the network +so it can be used with network-based printing tools like InvenTree's Brother plugin. +""" + +import argparse +import asyncio +import logging +import signal +import sys + +import usb.core +import usb.util + +import config + +# Brother printer USB vendor ID +BROTHER_VENDOR_ID = 0x04F9 + +# Known QL printer product IDs +PRODUCT_IDS = { + 0x2015: "QL-500", + 0x2016: "QL-550", + 0x2027: "QL-560", + 0x2028: "QL-570", + 0x2029: "QL-580N", + 0x201B: "QL-650TD", + 0x2042: "QL-700", + 0x2043: "QL-710W", + 0x2044: "QL-720NW", + 0x209B: "QL-800", + 0x209C: "QL-810W", + 0x209D: "QL-820NWB", + 0x2020: "QL-1050", + 0x202A: "QL-1060N", +} + +logger = logging.getLogger("brother-ql-bridge") + + +class USBPrinter: + """Handles USB communication with the Brother QL printer.""" + + def __init__(self, device=None): + self.dev = None + self.ep_out = None + self.ep_in = None + self._lock = asyncio.Lock() + + if device: + # Parse device string like "usb://0x04f9:0x2028" + if device.startswith("usb://"): + parts = device[6:].split(":") + vendor = int(parts[0], 16) + product = int(parts[1].split("/")[0], 16) + self.dev = usb.core.find(idVendor=vendor, idProduct=product) + else: + # Auto-detect Brother QL printer + self.dev = usb.core.find(idVendor=BROTHER_VENDOR_ID) + + if self.dev is None: + raise RuntimeError("No Brother QL printer found on USB") + + product_name = PRODUCT_IDS.get(self.dev.idProduct, f"Unknown (0x{self.dev.idProduct:04x})") + logger.info(f"Found printer: {product_name}") + + def connect(self): + """Initialize USB connection to the printer.""" + # Detach kernel driver if necessary + try: + if self.dev.is_kernel_driver_active(0): + self.dev.detach_kernel_driver(0) + logger.debug("Detached kernel driver") + except (usb.core.USBError, NotImplementedError): + pass + + # Set configuration + try: + self.dev.set_configuration() + except usb.core.USBError: + pass # Already configured + + # Get endpoints + cfg = self.dev.get_active_configuration() + intf = cfg[(0, 0)] + + self.ep_out = usb.util.find_descriptor( + intf, + custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) + == usb.util.ENDPOINT_OUT, + ) + self.ep_in = usb.util.find_descriptor( + intf, + custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) + == usb.util.ENDPOINT_IN, + ) + + if self.ep_out is None: + raise RuntimeError("Could not find USB OUT endpoint") + + logger.info("USB connection established") + + def write_sync(self, data: bytes) -> int: + """Write data to the printer synchronously with proper timeout.""" + # Write in chunks with longer timeout (15 seconds) + chunk_size = 32768 + total_written = 0 + for i in range(0, len(data), chunk_size): + chunk = data[i : i + chunk_size] + written = self.ep_out.write(chunk, timeout=15000) + total_written += written + return total_written + + async def write(self, data: bytes) -> int: + """Write data to the printer asynchronously.""" + async with self._lock: + try: + return await asyncio.get_event_loop().run_in_executor( + None, self.write_sync, data + ) + except usb.core.USBError as e: + logger.error(f"USB write error: {e}") + raise + + def close(self): + """Release USB resources.""" + if self.dev: + usb.util.dispose_resources(self.dev) + + +class PrinterBridge: + """TCP to USB bridge server.""" + + def __init__(self, printer: USBPrinter, host: str, port: int): + self.printer = printer + self.host = host + self.port = port + self.server = None + self.job_count = 0 + + async def handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ): + """Handle incoming print job from TCP client.""" + addr = writer.get_extra_info("peername") + self.job_count += 1 + job_id = self.job_count + logger.info(f"[Job {job_id}] Connection from {addr}") + + try: + # Buffer entire job first (Brother raster protocol works best this way) + chunks = [] + while True: + data = await reader.read(4096) + if not data: + break + chunks.append(data) + + job_data = b"".join(chunks) + logger.info(f"[Job {job_id}] Received {len(job_data)} bytes, sending to printer...") + + # Send complete job to printer + await self.printer.write(job_data) + logger.info(f"[Job {job_id}] Completed successfully") + + except Exception as e: + logger.error(f"[Job {job_id}] Error: {e}") + + finally: + writer.close() + await writer.wait_closed() + + async def start(self): + """Start the bridge server.""" + self.server = await asyncio.start_server( + self.handle_client, self.host, self.port + ) + + addrs = ", ".join(str(sock.getsockname()) for sock in self.server.sockets) + logger.info(f"Bridge server listening on {addrs}") + + async with self.server: + await self.server.serve_forever() + + async def stop(self): + """Stop the bridge server.""" + if self.server: + self.server.close() + await self.server.wait_closed() + + +async def main(): + parser = argparse.ArgumentParser( + description="Bridge a USB Brother QL printer to the network" + ) + parser.add_argument( + "-p", "--port", + type=int, + default=config.TCP_PORT, + help=f"TCP port to listen on (default: {config.TCP_PORT})" + ) + parser.add_argument( + "-H", "--host", + default=config.TCP_HOST, + help=f"Host/IP to bind to (default: {config.TCP_HOST})" + ) + parser.add_argument( + "-d", "--device", + default=config.USB_DEVICE, + help="USB device (e.g., usb://0x04f9:0x2028). Auto-detect if not specified" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable verbose logging" + ) + args = parser.parse_args() + + # Setup logging + log_level = logging.DEBUG if args.verbose else getattr(logging, config.LOG_LEVEL) + logging.basicConfig( + level=log_level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + # Initialize printer + try: + printer = USBPrinter(args.device) + printer.connect() + except RuntimeError as e: + logger.error(f"Failed to initialize printer: {e}") + sys.exit(1) + + # Create and run bridge + bridge = PrinterBridge(printer, args.host, args.port) + + # Handle shutdown signals + loop = asyncio.get_event_loop() + + def shutdown(): + logger.info("Shutting down...") + loop.create_task(bridge.stop()) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, shutdown) + + try: + await bridge.start() + except asyncio.CancelledError: + pass + finally: + printer.close() + logger.info("Bridge stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..98ee8dc --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +"""Configuration for Brother QL Bridge.""" + +# TCP server settings +TCP_HOST = "0.0.0.0" # Listen on all interfaces +TCP_PORT = 9100 # Standard raw printing port + +# USB printer settings +# Set to None for auto-detection, or specify like "usb://0x04f9:0x2028" +USB_DEVICE = None + +# Printer model (used for brother_ql compatibility) +PRINTER_MODEL = "QL-570" + +# Logging +LOG_LEVEL = "INFO" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ef64e9c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "brother-ql-bridge" +version = "0.1.0" +description = "Bridge a USB Brother QL printer to the network" +requires-python = ">=3.10" +dependencies = [ + "brother_ql>=0.9.4", + "pyusb>=1.2.1", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e379fe1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +brother_ql>=0.9.4 +pyusb>=1.2.1 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..545fbbe --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Brother QL Bridge - Raspberry Pi Deployment Script +set -e + +INSTALL_DIR="${INSTALL_DIR:-$HOME/brother-ql-bridge}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Brother QL Bridge Deployment ===" +echo "Install directory: $INSTALL_DIR" +echo "" + +# Check if running on Raspberry Pi +if [[ ! -f /proc/device-tree/model ]] || ! grep -q "Raspberry Pi" /proc/device-tree/model 2>/dev/null; then + echo "Warning: This doesn't appear to be a Raspberry Pi" + read -p "Continue anyway? [y/N] " -n 1 -r + echo + [[ $REPLY =~ ^[Yy]$ ]] || exit 1 +fi + +# Step 1: Install system dependencies +echo "[1/5] Installing system dependencies..." +sudo apt update +sudo apt install -y python3 python3-pip python3-venv libusb-1.0-0-dev + +# Step 2: Install uv if not present +echo "[2/5] Setting up Python package manager..." +if ! command -v uv &> /dev/null; then + echo "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +# Step 3: Setup project +echo "[3/5] Setting up project..." +mkdir -p "$INSTALL_DIR" +cp "$PROJECT_DIR/bridge.py" "$INSTALL_DIR/" +cp "$PROJECT_DIR/config.py" "$INSTALL_DIR/" +cp "$PROJECT_DIR/requirements.txt" "$INSTALL_DIR/" +cp "$PROJECT_DIR/pyproject.toml" "$INSTALL_DIR/" + +cd "$INSTALL_DIR" +uv venv +uv pip install -r requirements.txt + +# Step 4: Setup udev rules +echo "[4/5] Configuring USB permissions..." +if [[ ! -f /etc/udev/rules.d/99-brother-ql.rules ]]; then + sudo tee /etc/udev/rules.d/99-brother-ql.rules > /dev/null << 'EOF' +# Brother QL label printers +SUBSYSTEM=="usb", ATTR{idVendor}=="04f9", MODE="0666" +EOF + sudo udevadm control --reload-rules + sudo udevadm trigger + echo "USB permissions configured. You may need to unplug and replug the printer." +else + echo "USB permissions already configured." +fi + +# Step 5: Install systemd service +echo "[5/5] Installing systemd service..." +sudo tee /etc/systemd/system/brother-ql-bridge.service > /dev/null << EOF +[Unit] +Description=Brother QL USB-to-Network Bridge +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$INSTALL_DIR +ExecStart=$INSTALL_DIR/.venv/bin/python bridge.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable brother-ql-bridge + +echo "" +echo "=== Deployment Complete ===" +echo "" +echo "To start the service:" +echo " sudo systemctl start brother-ql-bridge" +echo "" +echo "To check status:" +echo " sudo systemctl status brother-ql-bridge" +echo "" +echo "To view logs:" +echo " sudo journalctl -u brother-ql-bridge -f" +echo "" +echo "To test manually:" +echo " cd $INSTALL_DIR && source .venv/bin/activate && python bridge.py -v"