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
This commit is contained in:
timmyhadwen
2025-12-21 16:46:14 +10:00
commit 4010ffc01b
7 changed files with 585 additions and 0 deletions

20
.gitignore vendored Normal file
View File

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

183
README.md Normal file
View File

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

261
bridge.py Normal file
View File

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

15
config.py Normal file
View File

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

9
pyproject.toml Normal file
View File

@@ -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",
]

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
brother_ql>=0.9.4
pyusb>=1.2.1

95
scripts/deploy.sh Executable file
View File

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