diff --git a/README.md b/README.md
index b9d58ef..3aa63aa 100644
--- a/README.md
+++ b/README.md
@@ -1,473 +1,106 @@
-# Phomemo-tools
+# Phomemo Tools
-This package is trying to provide tools to print pictures using
-the Phomemo M02, M110, M120, M220 and T02 thermal printers from Linux.
+Linux and macOS printing support for Phomemo thermal label printers.
-All the information here has been reverse-engineered sniffing
-the bluetooth packets emitted by the Android application.
+## Supported Printers
-## License
-
-This project is licensed under the GNU General Public License v3.
-
-Some image assets are provided under a separate license.
-See images/LICENSE for details.
-
-## 1. Usage
-
-### 1.1. Bluetooth
-
-* connection
-
-```
-$ bluetoothctl devices
-Device DC:0D:30:90:23:C7 Mr.in_M02
-$ bluetoothctl pair DC:0D:30:90:23:C7
-Attempting to pair with DC:0D:30:90:23:C7
-[CHG] Device DC:0D:30:90:23:C7 Connected: yes
-[CHG] Device DC:0D:30:90:23:C7 Bonded: yes
-[CHG] Device DC:0D:30:90:23:C7 ServicesResolved: yes
-[CHG] Device DC:0D:30:90:23:C7 Paired: yes
-Pairing successful
-$ sudo rfcomm connect 0 DC:0D:30:90:23:C7
- Connected /dev/rfcomm0 to DC:0D:30:90:23:C7 on channel 1
- Press CTRL-C for hangup
-```
-
-* Send the picture to the printer (the python script currently only works with M02 printers):
-
-```
- tools/phomemo-filter.py my_picture.png > /dev/rfcomm0
-```
-
-### 1.2. USB
-
-* Plug the USB printer cable
-
-* Check the printer is present:
-
-```
- $ lsusb
- ...
- Bus 003 Device 013: ID 0493:b002 MAG Technology Co., Ltd
- ...
-```
-
-You can see the serial port in the dmesg and in /dev:
-
-```
- $ dmesg
- ...
- usb 3-3.7.2: new full-speed USB device number 13 using xhci_hcd
- usb 3-3.7.2: New USB device found, idVendor=0493, idProduct=b002, bcdDevice= 3.00
- usb 3-3.7.2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
- usb 3-3.7.2: Product: USB Virtual COM
- usb 3-3.7.2: Manufacturer: Nuvoton
- usb 3-3.7.2: SerialNumber: A02014090305
- cdc_acm 3-3.7.2:1.0: ttyACM0: USB ACM device
- usblp 3-3.7.2:1.2: usblp0: USB Bidirectional printer dev 13 if 2 alt 0 proto 2 vid 0x0493 pid 0xB002
- $ ls -lrt /dev
- ...
- drwxr-xr-x. 2 root root 100 Dec 5 17:44 usb
- crw-rw----. 1 root dialout 166, 0 Dec 5 17:44 ttyACM0
- ...
- $ ls -lrt /dev/usb
- total 0
- crw-------. 1 root root 180, 96 Dec 5 16:46 hiddev0
- crw-------. 1 root root 180, 97 Dec 5 16:46 hiddev1
- crw-rw----. 1 root lp 180, 0 Dec 5 17:44 lp0
-```
-
-* Send the picture to the printer (the python script currently only works with M02 printers):
-
-You need to be root or in the lp group
-
-```
- # tools/phomemo-filter.py my_picture.png > /dev/usb/lp0
-```
-
-## 2. CUPS
-
-### 2.1. Installation
-
-On Fedora, the `phomemo-tools` RPM is available from COPR:
-
-```
- $ sudo dnf copr enable lvivier/phomemo-tools
- $ sudo dnf install phomemo-tools
-```
-
-On Debian you have to install cups:
-
-```
- $ sudo apt-get update
- $ sudo apt-get install cups
-```
-
-Next you need to ensure the required dependencies are installed (if this is skipped you will see a 'Filter Failure' error when trying to print):
-
-```
- $ sudo apt-get install python3-pil python3-pyusb
-```
-
-Finally once you are in the folder containing your copy of this repository you can build and install phomemo-tools files:
-
-```
- $ cd cups
- $ make
- $ sudo make install
-```
-
-### 2.2. Configuration
-
-#### 2.2.1. GUI
-
-##### 2.2.2.1.1. Pre-requisite
-
-On Fedora, SELinux seems to prevent the backend to create a bluetooth socket.
-If you have such error message in your syslog:
-
-```
-localhost.localdomain cupsd[2659]: Can\'t open Bluetooth connection: [Errno 13] Permission denied
-```
-
-You might need to disable SELinux enforcement to allow the backend to run correctly:
-
-```
- $ sudo semanage permissive -a cupsd_t
-```
-
-I didn't find a way to define correctly the SELinux rules to allow the backend
-to use bluetooth socket without to change the enforcement mode
-(the couple ausearch/audit2allow doesn't fix the problem).
-
-##### 2.2.2.1.1. Pair the printer
-
-1. Switch on the printer
-2. Open the "Settings" window:
-
-
-
-3. Select the "Bluetooth" Panel:
-
-
-
-4. Select your bluetooth printer (here "Mr.in_M02"):
-
-
-
-5. Your printer must be paired but not connected ("Disconnected"):
-
-
-
-6. Select the "Printers" Panel:
-
-
-
-You'll probably need to unlock it to be able to add a new printer.
-
-Click on "Add a Printer...".
-
-8. Select your printer and click on "Add":
-
-
-
-9. Your printer will appear in the printers list:
-
-
-
-10. Click on the settings menu of the printer and select "Printing Options":
-
-
-
-11. Select "Media Size Label 50mmx70mm" and click on "Test Page":
-
-
-
-12. Check the result:
-
-
-
-#### 2.2.2. CLI
-
-##### 2.2.2.1. Bluetooth
-
-This definition will use the "phomemo" backend to connect to the printer:
-
-###### 2.2.2.1.1 M02
-
-```
- $ sudo lpadmin -p M02 -E -v phomemo://DC0D309023C7 \
- -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
-```
-
-###### 2.2.2.1.2 M110, M120, M220
-
-Use ”Phomemo-M110.ppd.gz”. This driver is compatible with M110, M120, and M220.
-The -p option defines the printer name. It should be changed according to the printer used.
-
-```
- $ sudo lpadmin -p M110 -E -v phomemo://DC0D309023C7 \
- -P /usr/share/cups/model/Phomemo/Phomemo-M110.ppd.gz
-```
-
-##### 2.2.2.2. USB
-
-This definition will use the /dev/usb/lp0 device to connect to the printer:
-
-###### 2.2.2.2.1 M02
-
-```
- $ sudo lpadmin -p M02 -E -v serial:/dev/usb/lp0 \
- -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
-```
-
-###### 2.2.2.1.2 M110, M120, M220
-
-Use ”Phomemo-M110.ppd.gz”. This driver is compatible with M110, M120, and M220.
-The -p option defines the printer name. It should be changed according to the printer used.
-
-```
- $ sudo lpadmin -p M110 -E -v serial:/dev/usb/lp0 \
- -P /usr/share/cups/model/Phomemo/Phomemo-M110.ppd.gz
-```
-
-##### 2.2.2.3. Check printer options
-
-You can use the following command to check the options for your printer which will list the printer defaults with a "*":
-
-```
- $ lpoptions -d M02 -l
-```
-
-##### 2.2.2.4. Printing
-
-You can use the following command to print text using CUPS:
-
-```
- $ echo "This is test" | lp -d M02 -o media=w50h60 -
-```
-
-You can use the following command to print an image using CUPS:
-
-```
- $ lp -d M02 -o media=w50h60 my_picture.png
-```
-
-The M110, M120 & M220 printers have support for LabelWithGaps, Continuous and LabelWithMarks media types which can be specified as follows:
-
-```
- $ echo "This is test" | lp -d M110 -o media=w30h20 -o MediaType=Continuous
-```
-
-## 3. Image samples to use with the printer
-
-They are AI generated.
-
-They may be used, copied, modified, and redistributed freely, including for commercial purposes.
-
-They are not claimed to be public domain and are not licensed under the GPL.
-
-They do not have a human author in the sense of copyright law.
-
-
-
-### 3.1. Animals
+| Model | Resolution | Paper Width | Connection |
+|-------|-----------|-------------|------------|
+| M02 | 203 dpi | 50mm | Bluetooth, USB |
+| M02 Pro | 300 dpi | 50mm | Bluetooth, USB |
+| T02 | 203 dpi | 50mm | Bluetooth, USB |
+| M110 | 203 dpi | 20-50mm | Bluetooth, USB |
+| M120 | 203 dpi | 20-50mm | Bluetooth, USB |
+| M220 | 203 dpi | 20-70mm | Bluetooth, USB |
+| M421 | 203 dpi | 40-70mm | Bluetooth, USB |
+| D30 | 203 dpi | 30-40mm | Bluetooth, USB |
-|
|
|
|
-| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
-|
|
|
|
-|
|
|
|
-|
|
|
|
-|
|
|
|
-|
|
|
|
+## Platform Support
-### 3.2. Astronomy
+| Feature | Linux | macOS |
+|---------|-------|-------|
+| USB Printing | Yes | Yes |
+| Bluetooth Printing | Yes | Yes |
+| CUPS Integration | Yes | Yes |
+| Direct Printing | Yes | Yes |
-|
|
|
|
|
-| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
-|
|
|
|
|
+## Quick Start
-### 3.3. Birthday
+### Linux
-
+```bash
+# Install dependencies
+sudo apt-get install cups python3-pil python3-pyusb
-|
|
|
-| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
-|
|
|
+# Build and install
+cd cups
+make
+sudo make install
-### 3.4. Christmas
+# Add printer
+sudo lpadmin -p MyPrinter -E -v phomemo://AABBCCDDEEFF \
+ -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
-|
|
|
|
-| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
-
-|
|
|
-| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
-
-|
|
|
|
-| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
-|
|
|
|
-|
|
| |
-
-### 3.5. Everyday
-
-|
|
|
|
-| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-|
|
|
|
-|
|
|
|
-|
|
|
|
-|
|
|
|
-
-### 3.6. Flowers
-
-
-
-|
|
|
|
-| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
-|
|
|
|
-
-### 3.7. Landscape
-
-|
|
|
|
-| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
-|
|
|
|
-
-### 3.8. Objects
-
-|
|
|
|
-| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
-|
|
|
|
-
-### 3.9. People
-
-|
|
|
|
-| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
-|
|
|
|
-|
|
|
|
-|
|
| |
-
-### 3.10. Pictograms
-
-|
|
|
|
|
|
-| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
-|
|
|
|
|
|
-
-### 3.11. School-Office
-
-|
|
|
|
-| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
-|
|
|
|
-|
|
|
|
-
-### 3.12. To Do
-
-|
|
|
|
|
-| ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- |
-
-### 3.13. Tools
-
-|
|
|
|
|
-| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
-
-### 3.14 Frames
-
-
-
-|  |  |
-| --------------------------------------- | --------------------------------------- |
-|  |  |
-|  |  |
-
-|  |  |  |
-| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- |
-|  |  |  |
-
-# 4. Protocol for M02
-
-After dumpping bluetooth packets, it appears to be EPSON ESC/POS Commands.
-
-### 4.1. HEADER
-
-```
- 0x1b 0x40 -> command ESC @: initialize printer
- 0x1b 0x61 -> command ESC a: select justification
- 0x01 range: 0 (left-justification), 1 centered,
- 2 (right justification)
- 0x1f 0x11 0x02 0x04
+# Print
+lp -d MyPrinter image.png
```
-### 4.2. BLOCK MARKER
-
-```
- 0x1d 0x76 0x30 -> command GS v 0 : print raster bit image
- 0x00 mode: 0 (normal), 1 (double width),
- 2 (double-height), 3 (quadruple)
- 0x30 0x00 16bit, little-endian: number of bytes / line (48)
- 0xff 0x00 16bit, little-endian: number of lines in the image (255)
-```
+### macOS
- Values seem to be 16bit little-endian
+```bash
+# Install dependencies
+brew install libusb
+pip3 install Pillow pyusb pyobjc-framework-IOBluetooth
- If the picture is not finished, a new block marker must be sent with
- the remaining number of line (max is 255).
+# Build and install
+cd cups
+make filters
+sudo make install
-### 4.3. FOOTER
+# For Bluetooth CUPS printing
+cd ../macos
+./install-bt-helper.sh
-```
- 0x1b 0x64 -> command ESC d : print and feed n lines
- 0x02 number of line to feed
- 0x1b 0x64 -> command ESC d : print and feed n lines
- 0x02 number of line to feed
- 0x1f 0x11 0x08
- 0x1f 0x11 0x0e
- 0x1f 0x11 0x07
- 0x1f 0x11 0x09
+# Direct printing (simplest)
+python3 macos/print-bluetooth.py image.png
```
-### 4.4. IMAGE
+## Documentation
- Each line is 48 bytes long, each bit is a point (384 pt/line).
- size of a line is 48 mm (80 pt/cm or 203,2 dpi, as announced by Phomemo).
- ratio between height and width is 1.
+| Document | Description |
+|----------|-------------|
+| [docs/README.MD](docs/README.MD) | Complete documentation with protocol reference |
+| [macos/README.md](macos/README.md) | macOS-specific setup and troubleshooting |
+| [cups/README.md](cups/README.md) | CUPS drivers and filters |
-### 4.5. Printer message
+## Project Structure
```
-1a 04 5a
-1a 09 0c
-1a 07 01 00 00
-1a 08
-51 30 30 31 45 30 XX XX XX XX XX XX XX XX XX -> Serial Numer: E05C0XXXXXX
+phomemo-tools/
+├── cups/ # CUPS drivers
+│ ├── backend/ # Printer backends
+│ ├── filter/ # Raster filters
+│ ├── drv/ # PPD sources
+│ └── ppd/ # Compiled PPDs
+├── macos/ # macOS support
+│ ├── print-bluetooth.py # Direct BT printing
+│ ├── print-usb.py # Direct USB printing
+│ └── install-bt-helper.sh
+├── tools/ # Command-line tools
+│ ├── phomemo-filter.py # Image converter
+│ └── format-checker.py # Protocol validator
+├── images/ # Sample images
+└── docs/ # Documentation
```
-## 5. Protocol for M110/M120/M220
-
-Dumpping USB packets.
-
-### 5.1. HEADER
+## License
-```
- 0x1b 0x4e 0x0d -> Print Speed
- 0x05 range: 0x01 (Slow) - 0x05 (Fast)
- 0x1b 0x4e 0x04 -> Print Density
- 0x0f range: 01 - 0f
- 0x1f 0x11 -> Media Type
- 0x0a Mode: 0a="Label With Gaps" 0b="Continuas" 26="Label With Marks"
-```
+GNU General Public License v3
-### 5.2. BLOCK MARKER
+Image assets in `images/` are provided under a separate license - see `images/LICENSE`.
-```
- 0x1d 0x76 0x30 -> command GS v 0 : print raster bit image
- 0x00 mode: 0 (normal), 1 (double width),
- 2 (double-height), 3 (quadruple)
- 0x2b 0x00 16bit, little-endian: number of bytes / line (43)
- 0xf0 0x00 16bit, little-endian: number of lines in the image (240)
-```
+## Credits
-### 5.3. FOOTER
+Protocol reverse-engineered from Android Bluetooth packet captures.
-```
- 0x1f 0xf0 0x05 0x00
- 0x1f 0xf0 0x03 0x00
-```
+Some CUPS code based on [pretix/cups-fgl-printers](https://github.com/pretix/cups-fgl-printers).
diff --git a/cups/Makefile b/cups/Makefile
index 1904aaf..6445636 100644
--- a/cups/Makefile
+++ b/cups/Makefile
@@ -1,23 +1,126 @@
-all: ppds
+# Phomemo CUPS Driver Makefile
+# Supports both Linux and macOS (including Apple Silicon)
+
+# Platform detection
+UNAME := $(shell uname)
+ARCH := $(shell uname -m)
+
+# Platform-specific paths
+ifeq ($(UNAME), Darwin)
+ # macOS paths (using /usr/local to avoid SIP restrictions)
+ CUPS_BACKEND_DIR = /usr/local/lib/cups/backend
+ CUPS_FILTER_DIR = /usr/local/lib/cups/filter
+ CUPS_PPD_DIR = /Library/Printers/PPDs/Contents/Resources/Phomemo
+ CUPS_DRV_DIR = /Library/Printers/PPDs/Contents/Resources
+ # macOS install command (doesn't support -D flag)
+ INSTALL = install
+ INSTALL_DIR = install -d
+else
+ # Linux paths (default)
+ CUPS_BACKEND_DIR = $(DESTDIR)/usr/lib/cups/backend
+ CUPS_FILTER_DIR = $(DESTDIR)/usr/lib/cups/filter
+ CUPS_PPD_DIR = $(DESTDIR)/usr/share/cups/model/Phomemo
+ CUPS_DRV_DIR = $(DESTDIR)/usr/share/cups/drv
+ # Linux install command
+ INSTALL = install -D
+ INSTALL_DIR = install -d
+endif
+
+# Python module directories (relative to backend)
+BLUETOOTH_DIR = $(CUPS_BACKEND_DIR)/bluetooth
+USB_DIR = $(CUPS_BACKEND_DIR)/usb
+
+.PHONY: all ppds filters install install-linux install-darwin install-common clean
+
+all: ppds filters
ppds:
LC_ALL=C ppdc -z drv/*
+# Compile C filters (required for macOS due to Python sandbox restrictions)
+filters:
+ifeq ($(UNAME), Darwin)
+ gcc -o filter/rastertopm110 filter/rastertopm110.c -lcups -lcupsimage
+endif
+
+# Filter installation (platform-specific)
+install-filters:
+ $(INSTALL_DIR) $(CUPS_FILTER_DIR)
+ifeq ($(UNAME), Darwin)
+ # macOS: Use compiled C filter (Python blocked by sandbox)
+ $(INSTALL) -m 755 filter/rastertopm110 /usr/libexec/cups/filter/rastertopm110
+else
+ # Linux: Use Python filters
+ $(INSTALL) -m 755 filter/rastertopm02_t02.py $(CUPS_FILTER_DIR)/rastertopm02_t02
+ $(INSTALL) -m 755 filter/rastertopm110.py $(CUPS_FILTER_DIR)/rastertopm110
+ $(INSTALL) -m 755 filter/rastertopd30.py $(CUPS_FILTER_DIR)/rastertopd30
+endif
+
+# Backend and Python modules installation
+install-backend:
+ $(INSTALL_DIR) $(CUPS_BACKEND_DIR)
+ $(INSTALL_DIR) $(BLUETOOTH_DIR)
+ $(INSTALL_DIR) $(USB_DIR)
+ $(INSTALL) -m 755 backend/phomemo.py $(CUPS_BACKEND_DIR)/phomemo
+ $(INSTALL) -m 644 backend/platform.py $(CUPS_BACKEND_DIR)/platform.py
+ $(INSTALL) -m 644 backend/bluetooth/__init__.py $(BLUETOOTH_DIR)/__init__.py
+ $(INSTALL) -m 644 backend/bluetooth/base.py $(BLUETOOTH_DIR)/base.py
+ $(INSTALL) -m 644 backend/bluetooth/linux.py $(BLUETOOTH_DIR)/linux.py
+ $(INSTALL) -m 644 backend/bluetooth/darwin.py $(BLUETOOTH_DIR)/darwin.py
+ $(INSTALL) -m 644 backend/usb/__init__.py $(USB_DIR)/__init__.py
+ $(INSTALL) -m 644 backend/usb/base.py $(USB_DIR)/base.py
+ $(INSTALL) -m 644 backend/usb/linux.py $(USB_DIR)/linux.py
+ $(INSTALL) -m 644 backend/usb/darwin.py $(USB_DIR)/darwin.py
+
+# PPD files installation
+install-ppds:
+ $(INSTALL_DIR) $(CUPS_PPD_DIR)
+ $(INSTALL) -m 644 ppd/Phomemo-M02.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-M02Pro.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-T02.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-D30.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-M110.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-M220.ppd $(CUPS_PPD_DIR)/
+ $(INSTALL) -m 644 ppd/Phomemo-M421.ppd $(CUPS_PPD_DIR)/
+
+# Linux-specific installation (includes DRV files)
+install-linux: install-filters install-backend install-ppds
+ $(INSTALL_DIR) $(CUPS_DRV_DIR)
+ $(INSTALL) -m 644 drv/phomemo-m02_t02.drv $(CUPS_DRV_DIR)/
+ $(INSTALL) -m 644 drv/phomemo-m02pro.drv $(CUPS_DRV_DIR)/
+ $(INSTALL) -m 644 drv/phomemo-m110.drv $(CUPS_DRV_DIR)/
+ $(INSTALL) -m 644 drv/phomemo-m220.drv $(CUPS_DRV_DIR)/
+ $(INSTALL) -m 644 drv/phomemo-d30.drv $(CUPS_DRV_DIR)/
+ $(INSTALL) -m 644 drv/phomemo-m421.drv $(CUPS_DRV_DIR)/
+
+# macOS Bluetooth backend installation
+install-bt-backend:
+ $(INSTALL) -m 755 backend/phomemo-bt-socket /usr/libexec/cups/backend/phomemo-bt
+
+# macOS-specific installation
+install-darwin: install-filters install-ppds install-bt-backend
+ @echo ""
+ @echo "=== macOS Installation Complete ==="
+ @echo ""
+ @echo "The C filter has been installed to /usr/libexec/cups/filter/"
+ @echo "The Bluetooth backend has been installed to /usr/libexec/cups/backend/"
+ @echo ""
+ @echo "For Bluetooth printing, also run:"
+ @echo " cd ../macos && ./install-bt-helper.sh"
+ @echo ""
+ @echo "Restart CUPS to apply changes:"
+ @echo " sudo launchctl stop org.cups.cupsd"
+ @echo " sudo launchctl start org.cups.cupsd"
+ @echo ""
+
+# Platform-aware install target
install:
- install -D drv/phomemo-m02_t02.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D drv/phomemo-m02pro.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D ppd/Phomemo-M02.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D ppd/Phomemo-M02Pro.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D ppd/Phomemo-T02.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D ppd/Phomemo-D30.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D drv/phomemo-m110.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D drv/phomemo-m220.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D drv/phomemo-d30.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D drv/phomemo-m421.drv -t $(DESTDIR)/usr/share/cups/drv/
- install -D ppd/Phomemo-M110.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D ppd/Phomemo-M220.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -D ppd/Phomemo-M421.ppd.gz -t $(DESTDIR)/usr/share/cups/model/Phomemo
- install -Dm 755 filter/rastertopm02_t02.py $(DESTDIR)/usr/lib/cups/filter/rastertopm02_t02
- install -Dm 755 filter/rastertopm110.py $(DESTDIR)/usr/lib/cups/filter/rastertopm110
- install -Dm 755 filter/rastertopd30.py $(DESTDIR)/usr/lib/cups/filter/rastertopd30
- install -Dm 755 backend/phomemo.py $(DESTDIR)/usr/lib/cups/backend/phomemo
+ifeq ($(UNAME), Darwin)
+ $(MAKE) install-darwin
+else
+ $(MAKE) install-linux
+endif
+
+clean:
+ rm -f ppd/*.ppd.gz
+ rm -f filter/rastertopm110
diff --git a/cups/README.md b/cups/README.md
index 3a807db..6df81e3 100644
--- a/cups/README.md
+++ b/cups/README.md
@@ -1,4 +1,57 @@
-some codes taken from
+# Phomemo CUPS Drivers
-https://behind.pretix.eu/2018/01/20/cups-driver/
-https://github.com/pretix/cups-fgl-printers
+CUPS printing support for Phomemo thermal label printers on Linux and macOS.
+
+## Contents
+
+- `backend/` - CUPS backends for printer communication
+- `filter/` - CUPS filters for raster-to-printer conversion
+- `drv/` - PPD driver source files
+- `ppd/` - Compiled PPD files
+
+## Installation
+
+### Linux
+
+```bash
+make
+sudo make install
+```
+
+### macOS
+
+```bash
+make filters # Compile native C filter (required)
+sudo make install
+```
+
+For Bluetooth printing on macOS, also install the helper daemon:
+```bash
+cd ../macos
+./install-bt-helper.sh
+```
+
+## Filters
+
+| Filter | Printers | Language |
+|--------|----------|----------|
+| rastertopm02_t02 | M02, M02 Pro, T02 | Python (Linux), C (macOS) |
+| rastertopm110 | M110, M120, M220, M421 | Python (Linux), C (macOS) |
+| rastertopd30 | D30 | Python |
+
+The C filter (`filter/rastertopm110.c`) is required on macOS because Python filters are blocked by the CUPS sandbox.
+
+## Backends
+
+| Backend | Connection | Platform |
+|---------|------------|----------|
+| phomemo | Bluetooth/USB | Linux |
+| phomemo-bt | Bluetooth | macOS |
+
+The macOS Bluetooth backend uses a helper daemon architecture to work around TCC restrictions.
+
+## Credits
+
+Some code based on:
+- https://behind.pretix.eu/2018/01/20/cups-driver/
+- https://github.com/pretix/cups-fgl-printers
diff --git a/cups/backend/bluetooth/__init__.py b/cups/backend/bluetooth/__init__.py
new file mode 100644
index 0000000..604099e
--- /dev/null
+++ b/cups/backend/bluetooth/__init__.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""
+Bluetooth backend dispatcher for phomemo-tools.
+Automatically selects the appropriate platform-specific implementation.
+"""
+
+import sys
+import os
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from platform import get_platform
+
+_backend = None
+
+
+def get_bluetooth_backend():
+ """
+ Returns the appropriate Bluetooth backend for the current platform.
+
+ Returns:
+ BluetoothBackend: Platform-specific Bluetooth backend instance,
+ or None if Bluetooth is not available.
+ """
+ global _backend
+ if _backend is not None:
+ return _backend
+
+ system = get_platform()
+
+ if system == 'linux':
+ try:
+ from bluetooth.linux import LinuxBluetoothBackend
+ _backend = LinuxBluetoothBackend()
+ except ImportError as e:
+ print(f"WARNING: Linux Bluetooth unavailable: {e}", file=sys.stderr)
+ return None
+ elif system == 'darwin':
+ try:
+ from bluetooth.darwin import DarwinBluetoothBackend
+ _backend = DarwinBluetoothBackend()
+ except ImportError as e:
+ print(f"WARNING: macOS Bluetooth unavailable: {e}", file=sys.stderr)
+ return None
+ else:
+ print(f"WARNING: Unsupported platform for Bluetooth: {system}", file=sys.stderr)
+ return None
+
+ return _backend
+
+
+__all__ = ['get_bluetooth_backend']
diff --git a/cups/backend/bluetooth/base.py b/cups/backend/bluetooth/base.py
new file mode 100644
index 0000000..b2e577b
--- /dev/null
+++ b/cups/backend/bluetooth/base.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+Abstract base classes for Bluetooth functionality.
+Defines the interface that platform-specific implementations must follow.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Optional
+from dataclasses import dataclass
+
+
+@dataclass
+class BluetoothDevice:
+ """Platform-agnostic Bluetooth device representation."""
+ address: str # MAC address (format: XX:XX:XX:XX:XX:XX)
+ name: str # Device name as reported by Bluetooth
+ model: str # Extracted Phomemo model (e.g., 'M02', 'T02', 'M110')
+
+ def get_compact_address(self) -> str:
+ """Returns MAC address without colons (e.g., 'AABBCCDDEEFF')."""
+ return self.address.replace(':', '')
+
+ def get_cups_uri(self) -> str:
+ """Returns CUPS-compatible device URI."""
+ return f'phomemo://{self.get_compact_address()}'
+
+
+class BluetoothConnection(ABC):
+ """Abstract base class for Bluetooth RFCOMM connections."""
+
+ @abstractmethod
+ def send(self, data: bytes) -> int:
+ """
+ Send data to the connected printer.
+
+ Args:
+ data: Bytes to send
+
+ Returns:
+ Number of bytes sent
+
+ Raises:
+ IOError: If send fails
+ """
+ pass
+
+ @abstractmethod
+ def receive(self, size: int, timeout: float = 8.0) -> bytes:
+ """
+ Receive data from the printer.
+
+ Args:
+ size: Maximum number of bytes to receive
+ timeout: Timeout in seconds
+
+ Returns:
+ Received bytes (may be less than size)
+
+ Raises:
+ TimeoutError: If timeout expires
+ IOError: If receive fails
+ """
+ pass
+
+ @abstractmethod
+ def close(self) -> None:
+ """Close the connection and release resources."""
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+ return False
+
+
+class BluetoothBackend(ABC):
+ """Abstract base class for platform-specific Bluetooth backends."""
+
+ # Phomemo device name patterns
+ DEVICE_PREFIXES = ['Mr.in_', 'Mr.in']
+ DEVICE_EXACT_NAMES = ['T02']
+
+ @abstractmethod
+ def discover_devices(self) -> List[BluetoothDevice]:
+ """
+ Scan for paired Phomemo Bluetooth devices.
+
+ Returns:
+ List of discovered BluetoothDevice objects
+
+ Raises:
+ RuntimeError: If discovery fails
+ """
+ pass
+
+ @abstractmethod
+ def connect(self, address: str, channel: int = 1) -> BluetoothConnection:
+ """
+ Create an RFCOMM connection to a Bluetooth printer.
+
+ Args:
+ address: MAC address of the printer (format: XX:XX:XX:XX:XX:XX)
+ channel: RFCOMM channel number (default: 1)
+
+ Returns:
+ BluetoothConnection instance
+
+ Raises:
+ ConnectionError: If connection fails
+ ValueError: If address is invalid
+ """
+ pass
+
+ @classmethod
+ def extract_model(cls, device_name: str) -> Optional[str]:
+ """
+ Extract Phomemo model from device name.
+
+ Args:
+ device_name: Bluetooth device name
+
+ Returns:
+ Model name (e.g., 'M02', 'T02') or None if not a Phomemo device
+ """
+ if device_name in cls.DEVICE_EXACT_NAMES:
+ return device_name
+
+ for prefix in cls.DEVICE_PREFIXES:
+ if device_name.startswith(prefix):
+ return device_name[len(prefix):]
+
+ return None
+
+ @classmethod
+ def is_phomemo_device(cls, device_name: str) -> bool:
+ """
+ Check if a device name matches Phomemo device patterns.
+
+ Args:
+ device_name: Bluetooth device name
+
+ Returns:
+ True if device appears to be a Phomemo printer
+ """
+ return cls.extract_model(device_name) is not None
+
+
+__all__ = ['BluetoothDevice', 'BluetoothConnection', 'BluetoothBackend']
diff --git a/cups/backend/bluetooth/darwin.py b/cups/backend/bluetooth/darwin.py
new file mode 100644
index 0000000..851754e
--- /dev/null
+++ b/cups/backend/bluetooth/darwin.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+"""
+macOS Bluetooth implementation using IOBluetooth via PyObjC.
+
+Requirements:
+ pip install pyobjc-framework-IOBluetooth pyobjc-framework-CoreBluetooth
+"""
+
+import sys
+import time
+from typing import List, Optional
+
+# PyObjC imports - only available on macOS
+try:
+ import objc
+ from Foundation import NSObject, NSRunLoop, NSDate, NSDefaultRunLoopMode
+ from IOBluetooth import (
+ IOBluetoothDevice,
+ IOBluetoothRFCOMMChannel,
+ )
+ IOBT_AVAILABLE = True
+except ImportError as e:
+ IOBT_AVAILABLE = False
+ IOBT_IMPORT_ERROR = str(e)
+
+from bluetooth.base import BluetoothBackend, BluetoothConnection, BluetoothDevice
+
+
+class RFCOMMChannelDelegate(NSObject):
+ """
+ Objective-C delegate for IOBluetoothRFCOMMChannel callbacks.
+
+ IOBluetooth uses an event-driven model, so we need this delegate
+ to handle channel events (open, data received, close).
+ """
+
+ def init(self):
+ self = objc.super(RFCOMMChannelDelegate, self).init()
+ if self is None:
+ return None
+ self.received_data = bytearray()
+ self.is_open = False
+ self.is_closed = False
+ self.error = None
+ return self
+
+ def rfcommChannelOpenComplete_status_(self, channel, status):
+ """Called when RFCOMM channel open completes."""
+ if status == 0: # kIOReturnSuccess
+ self.is_open = True
+ else:
+ self.error = f"Channel open failed with status: {status}"
+
+ def rfcommChannelData_data_length_(self, channel, data, length):
+ """Called when data is received on the channel."""
+ # Convert NSData to bytes
+ if data and length > 0:
+ raw_bytes = data.bytes()
+ if raw_bytes:
+ self.received_data.extend(raw_bytes[:length])
+
+ def rfcommChannelClosed_(self, channel):
+ """Called when the channel closes."""
+ self.is_closed = True
+ self.is_open = False
+
+ def rfcommChannelWriteComplete_refcon_status_(self, channel, refcon, status):
+ """Called when a write operation completes."""
+ if status != 0:
+ self.error = f"Write failed with status: {status}"
+
+
+class DarwinBluetoothConnection(BluetoothConnection):
+ """
+ macOS RFCOMM Bluetooth connection using IOBluetooth framework.
+
+ Note: IOBluetooth is callback-based and requires NSRunLoop processing
+ for asynchronous operations.
+ """
+
+ def __init__(self, address: str, channel_id: int = 1, timeout: float = 10.0):
+ """
+ Create an RFCOMM connection to a Bluetooth device.
+
+ Args:
+ address: MAC address (format: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX)
+ channel_id: RFCOMM channel number
+ timeout: Connection timeout in seconds
+ """
+ if not IOBT_AVAILABLE:
+ raise ImportError(f"IOBluetooth not available: {IOBT_IMPORT_ERROR}")
+
+ # Normalize address format (IOBluetooth accepts both : and - separators)
+ normalized_addr = address.replace('-', ':').upper()
+
+ self.device = IOBluetoothDevice.deviceWithAddressString_(normalized_addr)
+ if self.device is None:
+ raise ValueError(f"Could not create device for address: {address}")
+
+ self.delegate = RFCOMMChannelDelegate.alloc().init()
+ self.channel = None
+ self._channel_id = channel_id
+
+ # Open the RFCOMM channel synchronously
+ result = self._open_channel_sync(channel_id, timeout)
+ if result != 0:
+ raise ConnectionError(
+ f"Failed to open RFCOMM channel {channel_id} to {address}: error {result}"
+ )
+
+ def _open_channel_sync(self, channel_id: int, timeout: float) -> int:
+ """
+ Synchronous wrapper around async channel open.
+
+ Spins the NSRunLoop until the channel opens or timeout expires.
+ """
+ # openRFCOMMChannelSync returns (status, channel) tuple
+ result = self.device.openRFCOMMChannelSync_withChannelID_delegate_(
+ None, # outChannel - will be set by method
+ channel_id,
+ self.delegate
+ )
+
+ # Handle different return types from PyObjC
+ if isinstance(result, tuple):
+ status, self.channel = result
+ else:
+ status = result
+ # Try to get channel from delegate or retry
+ self.channel = None
+
+ if status != 0:
+ return status
+
+ # Wait for delegate callback confirming open
+ deadline = time.time() + timeout
+ while not self.delegate.is_open and self.delegate.error is None:
+ # Process pending events
+ NSRunLoop.currentRunLoop().runMode_beforeDate_(
+ NSDefaultRunLoopMode,
+ NSDate.dateWithTimeIntervalSinceNow_(0.1)
+ )
+ if time.time() > deadline:
+ return -1 # Timeout
+
+ if self.delegate.error:
+ return -1
+
+ return 0
+
+ def send(self, data: bytes) -> int:
+ """Send data over the RFCOMM channel."""
+ if not self.channel or not self.delegate.is_open:
+ raise IOError("Channel not open")
+
+ # Clear any previous write error
+ self.delegate.error = None
+
+ # IOBluetooth writeSync expects data and length
+ result = self.channel.writeSync_length_(data, len(data))
+
+ if result != 0:
+ raise IOError(f"Write failed with status: {result}")
+
+ if self.delegate.error:
+ raise IOError(self.delegate.error)
+
+ return len(data)
+
+ def receive(self, size: int, timeout: float = 8.0) -> bytes:
+ """Receive data from the RFCOMM channel."""
+ if not self.channel or not self.delegate.is_open:
+ raise IOError("Channel not open")
+
+ # Clear received buffer
+ self.delegate.received_data = bytearray()
+
+ deadline = time.time() + timeout
+ while len(self.delegate.received_data) < size:
+ # Process pending events to receive callbacks
+ NSRunLoop.currentRunLoop().runMode_beforeDate_(
+ NSDefaultRunLoopMode,
+ NSDate.dateWithTimeIntervalSinceNow_(0.1)
+ )
+
+ if time.time() > deadline:
+ break
+
+ if self.delegate.is_closed:
+ break
+
+ return bytes(self.delegate.received_data)
+
+ def close(self) -> None:
+ """Close the RFCOMM channel."""
+ if self.channel:
+ try:
+ self.channel.closeChannel()
+ except Exception:
+ pass
+ self.channel = None
+
+
+class DarwinBluetoothBackend(BluetoothBackend):
+ """macOS Bluetooth backend using IOBluetooth framework."""
+
+ def __init__(self):
+ """Initialize the macOS Bluetooth backend."""
+ if not IOBT_AVAILABLE:
+ raise ImportError(
+ f"IOBluetooth framework not available. "
+ f"Install with: pip install pyobjc-framework-IOBluetooth\n"
+ f"Error: {IOBT_IMPORT_ERROR}"
+ )
+
+ def discover_devices(self) -> List[BluetoothDevice]:
+ """
+ Discover paired Phomemo Bluetooth devices.
+
+ Uses IOBluetoothDevice.pairedDevices() to get list of paired devices,
+ then filters for Phomemo printer name patterns.
+ """
+ devices = []
+
+ try:
+ paired = IOBluetoothDevice.pairedDevices()
+ except Exception as e:
+ print(f"WARNING: Failed to get paired devices: {e}", file=sys.stderr)
+ return devices
+
+ if paired is None:
+ return devices
+
+ for device in paired:
+ try:
+ name = device.name()
+ if not name:
+ continue
+
+ name = str(name)
+ model = self.extract_model(name)
+ if model is None:
+ continue
+
+ address = str(device.addressString())
+ devices.append(BluetoothDevice(
+ address=address,
+ name=name,
+ model=model
+ ))
+ except Exception as e:
+ print(f"WARNING: Error processing device: {e}", file=sys.stderr)
+ continue
+
+ return devices
+
+ def connect(self, address: str, channel: int = 1) -> BluetoothConnection:
+ """
+ Create an RFCOMM connection to a Bluetooth printer.
+
+ Args:
+ address: MAC address (format: XX:XX:XX:XX:XX:XX)
+ channel: RFCOMM channel number
+
+ Returns:
+ DarwinBluetoothConnection instance
+ """
+ return DarwinBluetoothConnection(address, channel)
+
+
+__all__ = ['DarwinBluetoothBackend', 'DarwinBluetoothConnection']
diff --git a/cups/backend/bluetooth/linux.py b/cups/backend/bluetooth/linux.py
new file mode 100644
index 0000000..2c0b5c3
--- /dev/null
+++ b/cups/backend/bluetooth/linux.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""
+Linux Bluetooth implementation using BlueZ/D-Bus.
+"""
+
+import sys
+import socket
+from typing import List
+
+import dbus
+
+from bluetooth.base import BluetoothBackend, BluetoothConnection, BluetoothDevice
+
+
+class LinuxBluetoothConnection(BluetoothConnection):
+ """Linux RFCOMM Bluetooth connection using kernel socket."""
+
+ def __init__(self, address: str, channel: int = 1):
+ """
+ Create an RFCOMM connection to a Bluetooth device.
+
+ Args:
+ address: MAC address (format: XX:XX:XX:XX:XX:XX)
+ channel: RFCOMM channel number
+ """
+ self.address = address
+ self.channel = channel
+ self.sock = socket.socket(
+ socket.AF_BLUETOOTH,
+ socket.SOCK_STREAM,
+ socket.BTPROTO_RFCOMM
+ )
+ try:
+ self.sock.connect((address, channel))
+ except OSError as e:
+ self.sock.close()
+ raise ConnectionError(f"Failed to connect to {address}: {e}")
+
+ def send(self, data: bytes) -> int:
+ """Send data over the RFCOMM connection."""
+ try:
+ self.sock.sendall(data)
+ return len(data)
+ except socket.error as e:
+ raise IOError(f"Send failed: {e}")
+
+ def receive(self, size: int, timeout: float = 8.0) -> bytes:
+ """Receive data from the RFCOMM connection."""
+ self.sock.settimeout(timeout)
+ try:
+ return self.sock.recv(size)
+ except socket.timeout:
+ raise TimeoutError(f"Receive timeout after {timeout}s")
+ except socket.error as e:
+ raise IOError(f"Receive failed: {e}")
+
+ def close(self) -> None:
+ """Close the socket connection."""
+ if self.sock:
+ try:
+ self.sock.close()
+ except Exception:
+ pass
+ self.sock = None
+
+
+class LinuxBluetoothBackend(BluetoothBackend):
+ """Linux Bluetooth backend using BlueZ via D-Bus."""
+
+ def __init__(self):
+ """Initialize D-Bus connection to BlueZ."""
+ try:
+ self.bus = dbus.SystemBus()
+ # Test connection to BlueZ
+ self.bus.get_object('org.bluez', '/')
+ except dbus.exceptions.DBusException as e:
+ raise RuntimeError(f"Failed to connect to BlueZ: {e}")
+
+ def discover_devices(self) -> List[BluetoothDevice]:
+ """
+ Discover paired Phomemo Bluetooth devices via BlueZ.
+
+ Returns:
+ List of discovered BluetoothDevice objects
+ """
+ devices = []
+
+ try:
+ bluez = self.bus.get_object('org.bluez', '/')
+ manager = dbus.Interface(bluez, 'org.freedesktop.DBus.ObjectManager')
+ objects = manager.GetManagedObjects()
+ except dbus.exceptions.DBusException as e:
+ print(f"WARNING: BlueZ discovery failed: {e}", file=sys.stderr)
+ return devices
+
+ for path, interfaces in objects.items():
+ if 'org.bluez.Device1' not in interfaces:
+ continue
+
+ properties = interfaces['org.bluez.Device1']
+
+ try:
+ name = str(properties['Name'])
+ except KeyError:
+ continue
+
+ model = self.extract_model(name)
+ if model is None:
+ continue
+
+ address = str(properties['Address'])
+ devices.append(BluetoothDevice(
+ address=address,
+ name=name,
+ model=model
+ ))
+
+ return devices
+
+ def connect(self, address: str, channel: int = 1) -> BluetoothConnection:
+ """
+ Create an RFCOMM connection to a Bluetooth printer.
+
+ Args:
+ address: MAC address (format: XX:XX:XX:XX:XX:XX)
+ channel: RFCOMM channel number
+
+ Returns:
+ LinuxBluetoothConnection instance
+ """
+ return LinuxBluetoothConnection(address, channel)
+
+
+__all__ = ['LinuxBluetoothBackend', 'LinuxBluetoothConnection']
diff --git a/cups/backend/entitlements.plist b/cups/backend/entitlements.plist
new file mode 100644
index 0000000..8e44eef
--- /dev/null
+++ b/cups/backend/entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.device.bluetooth
+
+
+
diff --git a/cups/backend/phomemo-bt-socket b/cups/backend/phomemo-bt-socket
new file mode 100755
index 0000000..5c0a6ea
--- /dev/null
+++ b/cups/backend/phomemo-bt-socket
@@ -0,0 +1,116 @@
+#!/bin/bash
+#
+# phomemo-bt-socket - CUPS backend for Phomemo Bluetooth printers via helper daemon
+#
+# This backend communicates with the phomemo-bt-helper daemon via Unix socket.
+# Uses bash and nc (netcat) to avoid Python sandbox issues.
+
+SOCKET_PATH="/tmp/phomemo-bt.sock"
+
+# Debug logging
+log() {
+ echo "DEBUG: $*" >&2
+}
+
+error() {
+ echo "ERROR: $*" >&2
+}
+
+# Convert number to 4-byte big-endian binary
+uint32_be() {
+ local n=$1
+ printf "\\x$(printf '%02x' $(( (n >> 24) & 0xff )))"
+ printf "\\x$(printf '%02x' $(( (n >> 16) & 0xff )))"
+ printf "\\x$(printf '%02x' $(( (n >> 8) & 0xff )))"
+ printf "\\x$(printf '%02x' $(( n & 0xff )))"
+}
+
+# Discovery mode - list available printers (simplified, returns nothing in sandbox)
+list_devices() {
+ # Can't run Python in CUPS sandbox, so discovery is limited
+ # Users should add printers manually with the phomemo-bt:// URI
+ :
+}
+
+# Print job
+print_job() {
+ local device_uri="$DEVICE_URI"
+ local job_id="$1"
+ local user="$2"
+ local title="$3"
+ local copies="$4"
+ local options="$5"
+ local file="$6"
+
+ log "Print job starting, URI: $device_uri"
+
+ # Parse Bluetooth address from URI: phomemo-bt://XX-XX-XX-XX-XX-XX
+ local address="${device_uri#phomemo-bt://}"
+ log "Bluetooth address: $address"
+
+ # Check if helper socket exists
+ if [ ! -S "$SOCKET_PATH" ]; then
+ error "Helper daemon not running. Please start it first:"
+ error " launchctl load ~/Library/LaunchAgents/com.phomemo.bt-helper.plist"
+ return 1
+ fi
+
+ # Create temp file for print data
+ local tmpfile="/var/spool/cups/tmp/phomemo-$$"
+ if [ -n "$file" ] && [ -f "$file" ]; then
+ cp "$file" "$tmpfile"
+ else
+ cat > "$tmpfile"
+ fi
+
+ local data_size=$(stat -f%z "$tmpfile" 2>/dev/null || echo "0")
+ log "Data size: $data_size bytes"
+
+ # Create request: 4-byte length + address
+ local addr_len=${#address}
+ local header_file="/var/spool/cups/tmp/phomemo-hdr-$$"
+ uint32_be $addr_len > "$header_file"
+ printf "%s" "$address" >> "$header_file"
+
+ # Send to helper via nc
+ # The helper daemon handles connection and data transfer
+ # We send header+address followed by print data, then close
+ (cat "$header_file"; sleep 0.3; cat "$tmpfile") | nc -U "$SOCKET_PATH" >/dev/null 2>&1 &
+ local nc_pid=$!
+
+ # Wait for nc to finish (data sent when nc exits)
+ wait $nc_pid 2>/dev/null
+ local result=$?
+
+ rm -f "$tmpfile" "$header_file"
+
+ # nc exit codes:
+ # 0 = success
+ # 141 = SIGPIPE (128+13) - server closed connection, data was sent
+ # Other = actual error
+ if [ $result -eq 0 ] || [ $result -eq 141 ]; then
+ log "Data sent to helper daemon (nc exit: $result)"
+ echo "STATE: +cups-waiting-for-job-completed" >&2
+ log "Print job complete"
+ return 0
+ else
+ error "Failed to connect to helper daemon (nc exit: $result)"
+ return 1
+ fi
+}
+
+# Main
+case $# in
+ 0)
+ # Discovery mode
+ list_devices
+ ;;
+ 5|6)
+ # Print job: job-id user title copies options [file]
+ print_job "$@"
+ ;;
+ *)
+ echo "Usage: $0 job-id user title copies options [file]" >&2
+ exit 1
+ ;;
+esac
diff --git a/cups/backend/phomemo-bt.m b/cups/backend/phomemo-bt.m
new file mode 100644
index 0000000..1f5434c
--- /dev/null
+++ b/cups/backend/phomemo-bt.m
@@ -0,0 +1,232 @@
+/*
+ * phomemo-bt - CUPS backend for Phomemo Bluetooth printers on macOS
+ *
+ * Compile:
+ * clang -o phomemo-bt phomemo-bt.m -framework Foundation -framework IOBluetooth
+ *
+ * Install:
+ * sudo cp phomemo-bt /usr/libexec/cups/backend/
+ * sudo chmod 755 /usr/libexec/cups/backend/phomemo-bt
+ */
+
+#import
+#import
+#include
+#include
+#include
+#include
+
+#define DEBUG(...) fprintf(stderr, "DEBUG: " __VA_ARGS__)
+
+/* RFCOMM Channel Delegate */
+@interface RFCOMMDelegate : NSObject
+@property (nonatomic, assign) BOOL isOpen;
+@property (nonatomic, assign) BOOL isClosed;
+@property (nonatomic, assign) IOReturn lastError;
+@end
+
+@implementation RFCOMMDelegate
+
+- (void)rfcommChannelOpenComplete:(IOBluetoothRFCOMMChannel *)channel status:(IOReturn)status {
+ if (status == kIOReturnSuccess) {
+ self.isOpen = YES;
+ DEBUG("Channel opened successfully\n");
+ } else {
+ self.lastError = status;
+ DEBUG("Channel open failed: %d\n", status);
+ }
+}
+
+- (void)rfcommChannelClosed:(IOBluetoothRFCOMMChannel *)channel {
+ self.isClosed = YES;
+ self.isOpen = NO;
+ DEBUG("Channel closed\n");
+}
+
+- (void)rfcommChannelWriteComplete:(IOBluetoothRFCOMMChannel *)channel refcon:(void *)refcon status:(IOReturn)status {
+ if (status != kIOReturnSuccess) {
+ self.lastError = status;
+ DEBUG("Write failed: %d\n", status);
+ }
+}
+
+@end
+
+/* List paired Phomemo printers */
+void list_devices(void) {
+ @autoreleasepool {
+ NSArray *paired = [IOBluetoothDevice pairedDevices];
+
+ if (!paired || [paired count] == 0) {
+ DEBUG("No paired devices found\n");
+ return;
+ }
+
+ for (IOBluetoothDevice *device in paired) {
+ NSString *name = [device name];
+ if (!name) continue;
+
+ /* Check if it looks like a Phomemo printer */
+ NSString *upperName = [name uppercaseString];
+ BOOL isPhomemo = NO;
+ NSString *model = @"Phomemo";
+
+ NSArray *patterns = @[@"M02", @"M110", @"M120", @"M220", @"M421", @"T02", @"D30"];
+ for (NSString *pattern in patterns) {
+ if ([upperName containsString:pattern]) {
+ isPhomemo = YES;
+ model = pattern;
+ break;
+ }
+ }
+
+ /* Also check for serial number pattern */
+ if (!isPhomemo) {
+ NSRegularExpression *regex = [NSRegularExpression
+ regularExpressionWithPattern:@"^[A-Z]\\d{3}[A-Z]\\d{2}[A-Z]\\d+$"
+ options:0 error:nil];
+ if ([regex numberOfMatchesInString:name options:0
+ range:NSMakeRange(0, [name length])] > 0) {
+ isPhomemo = YES;
+ }
+ }
+
+ if (isPhomemo) {
+ NSString *address = [device addressString];
+ /* CUPS device line format:
+ * device-class device-uri "make-model" "info" "device-id" */
+ printf("direct phomemo-bt://%s \"%s\" \"%s (%s)\" \"\"\n",
+ [address UTF8String],
+ [model UTF8String],
+ [name UTF8String],
+ [address UTF8String]);
+ }
+ }
+ }
+}
+
+/* Send data to printer via Bluetooth */
+int print_job(const char *uri, int fd) {
+ @autoreleasepool {
+ DEBUG("Print job starting, URI: %s\n", uri);
+
+ /* Parse Bluetooth address from URI: phomemo-bt://XX-XX-XX-XX-XX-XX */
+ const char *addr_start = strstr(uri, "://");
+ if (!addr_start) {
+ fprintf(stderr, "ERROR: Invalid URI format\n");
+ return 1;
+ }
+ addr_start += 3;
+
+ NSString *address = [NSString stringWithUTF8String:addr_start];
+ DEBUG("Connecting to: %s\n", [address UTF8String]);
+
+ /* Get device */
+ IOBluetoothDevice *device = [IOBluetoothDevice deviceWithAddressString:address];
+ if (!device) {
+ fprintf(stderr, "ERROR: Could not find device %s\n", [address UTF8String]);
+ return 1;
+ }
+
+ /* Create delegate */
+ RFCOMMDelegate *delegate = [[RFCOMMDelegate alloc] init];
+
+ /* Open RFCOMM channel */
+ IOBluetoothRFCOMMChannel *channel = nil;
+ IOReturn result = [device openRFCOMMChannelSync:&channel
+ withChannelID:1
+ delegate:delegate];
+
+ if (result != kIOReturnSuccess) {
+ fprintf(stderr, "ERROR: Failed to open RFCOMM channel: %d\n", result);
+ return 1;
+ }
+
+ /* Wait for channel to open */
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10.0];
+ while (!delegate.isOpen && delegate.lastError == 0) {
+ [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
+ beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
+ if ([[NSDate date] compare:deadline] == NSOrderedDescending) {
+ fprintf(stderr, "ERROR: Connection timeout\n");
+ [channel closeChannel];
+ return 1;
+ }
+ }
+
+ if (!delegate.isOpen) {
+ fprintf(stderr, "ERROR: Channel not open\n");
+ [channel closeChannel];
+ return 1;
+ }
+
+ /* Small delay to stabilize connection */
+ usleep(500000);
+
+ fprintf(stderr, "STATE: +sending-data\n");
+
+ /* Read and send data in chunks */
+ uint8_t buffer[512];
+ ssize_t bytes_read;
+ size_t total_sent = 0;
+
+ while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
+ result = [channel writeSync:buffer length:(UInt16)bytes_read];
+ if (result != kIOReturnSuccess) {
+ fprintf(stderr, "ERROR: Write failed: %d\n", result);
+ [channel closeChannel];
+ return 1;
+ }
+ total_sent += bytes_read;
+ usleep(10000); /* Small delay between chunks */
+ }
+
+ DEBUG("Sent %zu bytes\n", total_sent);
+
+ /* Close channel */
+ [channel closeChannel];
+
+ fprintf(stderr, "STATE: +cups-waiting-for-job-completed\n");
+ DEBUG("Print job complete\n");
+
+ return 0;
+ }
+}
+
+int main(int argc, char *argv[]) {
+ /* No arguments = discovery mode */
+ if (argc == 1) {
+ list_devices();
+ return 0;
+ }
+
+ /* With arguments = print job */
+ /* CUPS calls: backend job-id user title copies options [file] */
+ if (argc < 6) {
+ fprintf(stderr, "Usage: %s job-id user title copies options [file]\n", argv[0]);
+ return 1;
+ }
+
+ const char *device_uri = getenv("DEVICE_URI");
+ if (!device_uri) {
+ fprintf(stderr, "ERROR: DEVICE_URI not set\n");
+ return 1;
+ }
+
+ int input_fd = 0; /* stdin */
+ if (argc > 6) {
+ input_fd = open(argv[6], O_RDONLY);
+ if (input_fd < 0) {
+ perror("ERROR: Cannot open input file");
+ return 1;
+ }
+ }
+
+ int result = print_job(device_uri, input_fd);
+
+ if (argc > 6) {
+ close(input_fd);
+ }
+
+ return result;
+}
diff --git a/cups/backend/phomemo.py b/cups/backend/phomemo.py
index fb255e7..0e80c87 100755
--- a/cups/backend/phomemo.py
+++ b/cups/backend/phomemo.py
@@ -1,147 +1,191 @@
-#! /usr/bin/python3
+#!/usr/bin/env python3
+"""
+Phomemo CUPS Backend
+
+Cross-platform backend supporting both Linux (BlueZ) and macOS (IOBluetooth).
+Handles printer discovery and job submission via Bluetooth or USB.
+
+Usage:
+ Discovery mode (no arguments):
+ phomemo
+
+ Print mode (called by CUPS):
+ DEVICE_URI=phomemo://AABBCCDDEEFF phomemo job user title copies options [file]
+"""
import sys
import os
-import subprocess
-import dbus
-import socket
-from urllib.parse import quote
-bus = dbus.SystemBus()
+# Add current directory to path for module imports
+backend_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, backend_dir)
+
+# Import platform-agnostic backends
+from bluetooth import get_bluetooth_backend
+from usb import get_usb_backend
+
+# CUPS device ID string
+DEVICE_ID = 'CLS:PRINTER;CMD:EPSON;DES:Thermal Printer;MFG:Phomemo;MDL:'
+
+
+def format_mac_address(compact: str) -> str:
+ """
+ Convert compact MAC address to colon-separated format.
+
+ Args:
+ compact: MAC address without separators (e.g., 'AABBCCDDEEFF')
+
+ Returns:
+ Colon-separated MAC address (e.g., 'AA:BB:CC:DD:EE:FF')
+ """
+ return ':'.join(compact[i:i+2] for i in range(0, 12, 2))
+
-device_id = 'CLS:PRINTER;CMD:EPSON;DES:Thermal Printer;MFG:Phomemo;MDL:'
def scan_bluetooth():
+ """
+ Discover Bluetooth printers and output CUPS discovery format.
+ """
+ backend = get_bluetooth_backend()
+ if backend is None:
+ return
+
try:
- bluez = bus.get_object('org.bluez', '/')
- except dbus.exceptions.DBusException:
- print("WARNING: no bluetooth interface", file=sys.stderr)
+ devices = backend.discover_devices()
+ except Exception as e:
+ print(f"WARNING: Bluetooth discovery failed: {e}", file=sys.stderr)
return
- manager = dbus.Interface(bluez, 'org.freedesktop.DBus.ObjectManager')
+ for device in devices:
+ device_uri = device.get_cups_uri()
+ device_make_and_model = f'Phomemo {device.model}'
- objects = manager.GetManagedObjects()
+ # CUPS discovery output format:
+ # class URI "make and model" "info" "device-id"
+ print(
+ f'direct {device_uri} "{device_make_and_model}" '
+ f'"{device_make_and_model} bluetooth {device.address}" '
+ f'"{DEVICE_ID}{device.model} (BT);"'
+ )
- for path, interfaces in objects.items():
- if 'org.bluez.Device1' not in interfaces.keys():
- continue
- properties = interfaces['org.bluez.Device1']
+def scan_usb():
+ """
+ Discover USB printers and output CUPS discovery format.
+ """
+ backend = get_usb_backend()
+ if backend is None:
+ return
- try:
- name = properties['Name']
- except KeyError:
- continue
-
- if (name.startswith('Mr.in')):
- model = name[6:]
- elif (name == 'T02'):
- model = name
- else:
- continue
-
- address = properties['Address']
- device_uri = 'phomemo://' + address[0:2:]+address[3:5:]+address[6:8:]+address[9:11:]+address[12:14:]+address[15:17:]
- device_make_and_model = 'Phomemo ' + model
-
- print('direct ' + device_uri + ' "' + device_make_and_model + '" "' +
- device_make_and_model + ' bluetooth ' + address + '" "' + device_id + model + ' (BT);"')
-
-class find_class(object):
- def __init__(self, class_):
- self._class = class_
- def __call__(self, device):
- # first, let's check the device
- if device.bDeviceClass == self._class:
- return True
- # ok, transverse all devices to find an
- # interface that matches our class
- for cfg in device:
- # find_descriptor: what's it?
- intf = usb.util.find_descriptor(
- cfg,
- bInterfaceClass=self._class
- )
- if intf is not None:
- return True
-
- return False
+ try:
+ devices = backend.discover_devices()
+ except Exception as e:
+ print(f"WARNING: USB discovery failed: {e}", file=sys.stderr)
+ return
-def scan_usb():
- printers = usb.core.find(find_all=1, custom_match=find_class(7), idVendor=0x0493)
- for printer in printers:
- for cfg in printer:
- intf = usb.util.find_descriptor(cfg, bInterfaceClass=7)
- if intf is None:
- continue
- Interface = intf.bInterfaceNumber
- break
- if printer.idProduct == 0xb002:
- model = 'M02'
- elif printer.idProduct == 0x8760:
- model = 'M110'
- else:
- model = 'Unknown(0x%04x)' % (printer.idProduct)
- usb.util.get_langids(printer)
- SerialNumber = usb.util.get_string(printer, printer.iSerialNumber)
- device_uri = 'usb://%s/%s?serial=%s&interface=%d' % (quote(printer.manufacturer), quote(printer.product), SerialNumber, Interface)
- device_make_and_model = 'Phomemo ' + model
- print('direct ' + device_uri + ' "' + device_make_and_model + '" "' +
- device_make_and_model + ' USB ' + SerialNumber + '" "' + device_id + model + ' (USB);"')
-
-
-if len(sys.argv) == 1:
- scan_bluetooth()
+ for device in devices:
+ device_uri = device.get_cups_uri()
+ device_make_and_model = f'Phomemo {device.model}'
+
+ print(
+ f'direct {device_uri} "{device_make_and_model}" '
+ f'"{device_make_and_model} USB {device.serial}" '
+ f'"{DEVICE_ID}{device.model} (USB);"'
+ )
+
+
+def print_job(address: str):
+ """
+ Connect to printer and send print job data.
+
+ Args:
+ address: Bluetooth MAC address (format: XX:XX:XX:XX:XX:XX)
+ """
+ backend = get_bluetooth_backend()
+ if backend is None:
+ print("ERROR: Bluetooth not available on this platform", file=sys.stderr)
+ sys.exit(1)
+
+ try:
+ print('STATE: +connecting-to-device')
+ conn = backend.connect(address, channel=1)
+
+ print('STATE: +sending-data')
+ with os.fdopen(sys.stdin.fileno(), 'rb', closefd=False) as stdin:
+ while True:
+ data = stdin.read(8192)
+ if not data:
+ break
+ conn.send(data)
+ print(f'DEBUG: sent {len(data)}')
+
+ # Wait for printer acknowledgment before closing
+ # This prevents premature connection close which stops printing
+ print('STATE: +receiving-data')
+ try:
+ received = conn.receive(28, timeout=8.0)
+ if received:
+ hex_str = " 0x".join(f"{b:02x}" for b in received)
+ print(f'DEBUG: {hex_str}')
+ except TimeoutError:
+ pass
+ except Exception as e:
+ print(f'DEBUG: receive error (non-fatal): {e}')
+
+ conn.close()
+ print('STATE: -connecting-to-device')
+ print('STATE: -sending-data')
+ print('STATE: -receiving-data')
+
+ except ConnectionError as e:
+ print(f"ERROR: Can't open Bluetooth connection: {e}", file=sys.stderr)
+ sys.exit(1)
+ except IOError as e:
+ print(f"ERROR: Cannot write data: {e}", file=sys.stderr)
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR: Unexpected error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+def main():
+ """Main entry point for CUPS backend."""
+
+ # Discovery mode: no arguments
+ if len(sys.argv) == 1:
+ scan_bluetooth()
+ scan_usb()
+ sys.exit(0)
+
+ # Print mode: DEVICE_URI environment variable required
+ device_uri = os.environ.get('DEVICE_URI')
+ if not device_uri:
+ print("ERROR: DEVICE_URI environment variable not set", file=sys.stderr)
+ sys.exit(1)
+
+ # Parse device URI
try:
- import usb.core
- import usb.util
- except ModuleNotFoundError:
- print("WARNING: Please install python3-usb to support usb-discovery", file=sys.stderr)
- exit(0)
- scan_usb()
- exit(0)
-
-try:
- device_uri = os.environ['DEVICE_URI']
-except:
- exit(1)
-
-uri = device_uri.split('://')
-
-if uri[0] != 'phomemo':
- exit(1)
-
-a = uri[1]
-bdaddr = a[0:2:] + ':' + a[2:4:] + ':' + a[4:6:] + ':' + a[6:8:] + ':' + a[8:10:] + ':' + a[10:12:]
-
-print('DEBUG: ' + sys.argv[0] +' device ' + bdaddr)
-
-try:
- print('STATE: +connecting-to-device')
- sock = socket.socket(socket.AF_BLUETOOTH, proto=socket.BTPROTO_RFCOMM)
- sock.connect((bdaddr, 1))
- print('STATE: +sending-data')
- with os.fdopen(sys.stdin.fileno(), 'rb', closefd=False) as stdin:
- while True:
- data = stdin.read(8192)
- size = len(data)
- if size == 0:
- break
- sock.sendall(data)
- print('DEBUG: sent %d' % (size))
-except OSError as btErr:
- print("ERROR: Can't open Bluetooth connection: " + str(btErr), file=sys.stderr)
- exit(1)
-except socket.error as SockErr:
- print("ERROR: Cannot write data: " + str(SockErr), file=sys.stderr)
- exit(1)
-try:
- # we need to wait the printer answer before closing the socket
- # otherwise the print is stopped
- print('STATE: +receiving-data')
- sock.settimeout(8)
- while True:
- received = sock.recv(28)
- print('DEBUG: ' + " 0x".join("%02x" % b for b in received))
-except:
- pass
-exit(0)
+ scheme, address = device_uri.split('://', 1)
+ except ValueError:
+ print(f"ERROR: Invalid device URI format: {device_uri}", file=sys.stderr)
+ sys.exit(1)
+
+ if scheme != 'phomemo':
+ print(f"ERROR: Unsupported URI scheme: {scheme}", file=sys.stderr)
+ sys.exit(1)
+
+ # Convert compact address to MAC format
+ if len(address) == 12:
+ bdaddr = format_mac_address(address)
+ elif ':' in address and len(address) == 17:
+ bdaddr = address
+ else:
+ print(f"ERROR: Invalid Bluetooth address: {address}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f'DEBUG: {sys.argv[0]} device {bdaddr}')
+ print_job(bdaddr)
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/cups/backend/platform.py b/cups/backend/platform.py
new file mode 100644
index 0000000..d5adc79
--- /dev/null
+++ b/cups/backend/platform.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""
+Platform detection and utilities for phomemo-tools.
+Provides cross-platform path resolution and capability detection.
+"""
+
+import platform
+import os
+import struct
+
+
+def get_platform():
+ """
+ Returns the current platform identifier.
+
+ Returns:
+ str: 'linux', 'darwin', or 'unknown'
+ """
+ system = platform.system().lower()
+ if system in ('linux', 'darwin'):
+ return system
+ return 'unknown'
+
+
+def is_apple_silicon():
+ """
+ Detects if running on Apple Silicon (arm64 macOS).
+
+ Returns:
+ bool: True if Apple Silicon, False otherwise
+ """
+ return get_platform() == 'darwin' and platform.machine() == 'arm64'
+
+
+def is_macos():
+ """
+ Detects if running on macOS.
+
+ Returns:
+ bool: True if macOS, False otherwise
+ """
+ return get_platform() == 'darwin'
+
+
+def is_linux():
+ """
+ Detects if running on Linux.
+
+ Returns:
+ bool: True if Linux, False otherwise
+ """
+ return get_platform() == 'linux'
+
+
+def get_cups_paths():
+ """
+ Returns platform-appropriate CUPS installation directories.
+
+ Returns:
+ dict: Dictionary with keys 'backend', 'filter', 'ppd', 'drv'
+ """
+ if is_macos():
+ return {
+ 'backend': '/usr/local/lib/cups/backend',
+ 'filter': '/usr/local/lib/cups/filter',
+ 'ppd': '/Library/Printers/PPDs/Contents/Resources/Phomemo',
+ 'drv': '/Library/Printers/PPDs/Contents/Resources',
+ }
+ else:
+ # Linux defaults
+ return {
+ 'backend': '/usr/lib/cups/backend',
+ 'filter': '/usr/lib/cups/filter',
+ 'ppd': '/usr/share/cups/model/Phomemo',
+ 'drv': '/usr/share/cups/drv',
+ }
+
+
+def check_bluetooth_available():
+ """
+ Checks if Bluetooth stack is accessible on this platform.
+
+ Returns:
+ bool: True if Bluetooth is available, False otherwise
+ """
+ if is_linux():
+ try:
+ import dbus
+ bus = dbus.SystemBus()
+ bus.get_object('org.bluez', '/')
+ return True
+ except Exception:
+ return False
+ elif is_macos():
+ try:
+ from IOBluetooth import IOBluetoothDevice
+ return True
+ except ImportError:
+ return False
+ return False
+
+
+def check_usb_available():
+ """
+ Checks if USB support is available.
+
+ Returns:
+ bool: True if PyUSB is available, False otherwise
+ """
+ try:
+ import usb.core
+ return True
+ except ImportError:
+ return False
diff --git a/cups/backend/usb/__init__.py b/cups/backend/usb/__init__.py
new file mode 100644
index 0000000..44e1842
--- /dev/null
+++ b/cups/backend/usb/__init__.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""
+USB backend dispatcher for phomemo-tools.
+Automatically selects the appropriate platform-specific implementation.
+"""
+
+import sys
+import os
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from platform import get_platform
+
+_backend = None
+
+
+def get_usb_backend():
+ """
+ Returns the appropriate USB backend for the current platform.
+
+ Returns:
+ USBBackend: Platform-specific USB backend instance,
+ or None if USB support is not available.
+ """
+ global _backend
+ if _backend is not None:
+ return _backend
+
+ system = get_platform()
+
+ if system == 'linux':
+ try:
+ from usb.linux import LinuxUSBBackend
+ _backend = LinuxUSBBackend()
+ except ImportError as e:
+ print(f"WARNING: Linux USB unavailable: {e}", file=sys.stderr)
+ return None
+ elif system == 'darwin':
+ try:
+ from usb.darwin import DarwinUSBBackend
+ _backend = DarwinUSBBackend()
+ except ImportError as e:
+ print(f"WARNING: macOS USB unavailable: {e}", file=sys.stderr)
+ return None
+ else:
+ print(f"WARNING: Unsupported platform for USB: {system}", file=sys.stderr)
+ return None
+
+ return _backend
+
+
+__all__ = ['get_usb_backend']
diff --git a/cups/backend/usb/base.py b/cups/backend/usb/base.py
new file mode 100644
index 0000000..aab5a91
--- /dev/null
+++ b/cups/backend/usb/base.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""
+Abstract base classes for USB functionality.
+Defines the interface that platform-specific implementations must follow.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Optional
+from dataclasses import dataclass
+from urllib.parse import quote
+
+
+@dataclass
+class USBDevice:
+ """Platform-agnostic USB device representation."""
+ vendor_id: int
+ product_id: int
+ serial: str
+ model: str
+ interface: int
+ manufacturer: str
+ product: str
+ device_path: Optional[str] = None # Platform-specific device path
+
+ def get_cups_uri(self) -> str:
+ """Returns CUPS-compatible device URI."""
+ return (
+ f'usb://{quote(self.manufacturer)}/{quote(self.product)}'
+ f'?serial={self.serial}&interface={self.interface}'
+ )
+
+
+class USBBackend(ABC):
+ """Abstract base class for platform-specific USB backends."""
+
+ # Phomemo vendor ID (MAG Technology Co., Ltd)
+ PHOMEMO_VENDOR_ID = 0x0493
+
+ # Known Phomemo product IDs
+ PRODUCT_MAP = {
+ 0xb002: 'M02',
+ 0x8760: 'M110',
+ # Add more product IDs as discovered
+ }
+
+ @abstractmethod
+ def discover_devices(self) -> List[USBDevice]:
+ """
+ Scan for connected Phomemo USB printers.
+
+ Returns:
+ List of discovered USBDevice objects
+
+ Raises:
+ RuntimeError: If discovery fails
+ """
+ pass
+
+ @classmethod
+ def get_model_name(cls, product_id: int) -> str:
+ """
+ Get model name from USB product ID.
+
+ Args:
+ product_id: USB product ID
+
+ Returns:
+ Model name or 'Unknown(0xXXXX)' if not recognized
+ """
+ return cls.PRODUCT_MAP.get(product_id, f'Unknown(0x{product_id:04x})')
+
+
+__all__ = ['USBDevice', 'USBBackend']
diff --git a/cups/backend/usb/darwin.py b/cups/backend/usb/darwin.py
new file mode 100644
index 0000000..cd51685
--- /dev/null
+++ b/cups/backend/usb/darwin.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""
+macOS USB implementation using PyUSB and system tools.
+"""
+
+import sys
+import glob
+import subprocess
+import re
+from typing import List, Optional
+
+try:
+ import usb.core
+ import usb.util
+ PYUSB_AVAILABLE = True
+except ImportError:
+ PYUSB_AVAILABLE = False
+
+from usb.base import USBBackend, USBDevice
+
+
+class FindPrinterClass:
+ """Custom matcher for USB printer class devices."""
+
+ PRINTER_CLASS = 7 # USB Printer class
+
+ def __init__(self):
+ pass
+
+ def __call__(self, device):
+ """Check if device is a printer class device."""
+ if device.bDeviceClass == self.PRINTER_CLASS:
+ return True
+
+ for cfg in device:
+ intf = usb.util.find_descriptor(
+ cfg,
+ bInterfaceClass=self.PRINTER_CLASS
+ )
+ if intf is not None:
+ return True
+
+ return False
+
+
+class DarwinUSBBackend(USBBackend):
+ """macOS USB backend using PyUSB and ioreg."""
+
+ def __init__(self):
+ """Initialize the macOS USB backend."""
+ if not PYUSB_AVAILABLE:
+ raise ImportError(
+ "PyUSB not available. Install with: pip install pyusb"
+ )
+
+ def _find_cu_device(self, serial: Optional[str] = None) -> Optional[str]:
+ """
+ Find /dev/cu.usbmodem* device, optionally matching serial.
+
+ Args:
+ serial: USB serial number to match (optional)
+
+ Returns:
+ Device path or None
+ """
+ candidates = glob.glob('/dev/cu.usbmodem*')
+
+ if not candidates:
+ return None
+
+ if serial and len(candidates) > 1:
+ # Try to match by checking ioreg for serial
+ # This is a best-effort match
+ for candidate in candidates:
+ if serial in candidate:
+ return candidate
+
+ # Return first match if no serial match or single device
+ return candidates[0] if candidates else None
+
+ def _get_usb_info_from_ioreg(self) -> dict:
+ """
+ Get USB device information from ioreg.
+
+ Returns:
+ Dictionary mapping serial numbers to device info
+ """
+ info = {}
+
+ try:
+ result = subprocess.run(
+ ['ioreg', '-p', 'IOUSB', '-l', '-w', '0'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode != 0:
+ return info
+
+ current_device = {}
+ for line in result.stdout.split('\n'):
+ # Look for Phomemo vendor ID (0x0493 = 1171 decimal)
+ if 'idVendor' in line:
+ match = re.search(r'= (\d+)', line)
+ if match and int(match.group(1)) == self.PHOMEMO_VENDOR_ID:
+ current_device['vendor'] = self.PHOMEMO_VENDOR_ID
+
+ elif 'idProduct' in line and 'vendor' in current_device:
+ match = re.search(r'= (\d+)', line)
+ if match:
+ current_device['product_id'] = int(match.group(1))
+
+ elif 'USB Serial Number' in line and 'vendor' in current_device:
+ match = re.search(r'"([^"]+)"', line)
+ if match:
+ serial = match.group(1)
+ current_device['serial'] = serial
+ info[serial] = current_device.copy()
+ current_device = {}
+
+ except Exception as e:
+ print(f"WARNING: ioreg parsing failed: {e}", file=sys.stderr)
+
+ return info
+
+ def discover_devices(self) -> List[USBDevice]:
+ """
+ Discover connected Phomemo USB printers on macOS.
+
+ Uses both PyUSB for device enumeration and ioreg for
+ mapping to /dev/cu.* device paths.
+
+ Returns:
+ List of discovered USBDevice objects
+ """
+ devices = []
+
+ # Get supplementary info from ioreg
+ ioreg_info = self._get_usb_info_from_ioreg()
+
+ try:
+ printers = usb.core.find(
+ find_all=True,
+ custom_match=FindPrinterClass(),
+ idVendor=self.PHOMEMO_VENDOR_ID
+ )
+ except usb.core.NoBackendError:
+ print(
+ "WARNING: No USB backend found. Install libusb: brew install libusb",
+ file=sys.stderr
+ )
+ return devices
+ except Exception as e:
+ print(f"WARNING: USB discovery failed: {e}", file=sys.stderr)
+ return devices
+
+ for printer in printers:
+ try:
+ # Find printer interface
+ interface_num = None
+ for cfg in printer:
+ intf = usb.util.find_descriptor(
+ cfg,
+ bInterfaceClass=FindPrinterClass.PRINTER_CLASS
+ )
+ if intf is not None:
+ interface_num = intf.bInterfaceNumber
+ break
+
+ if interface_num is None:
+ continue
+
+ # Get device strings
+ try:
+ usb.util.get_langids(printer)
+ serial = usb.util.get_string(printer, printer.iSerialNumber)
+ manufacturer = printer.manufacturer or 'Phomemo'
+ product = printer.product or 'Thermal Printer'
+ except Exception:
+ serial = 'Unknown'
+ manufacturer = 'Phomemo'
+ product = 'Thermal Printer'
+
+ model = self.get_model_name(printer.idProduct)
+
+ # Find corresponding /dev/cu.* device
+ device_path = self._find_cu_device(serial)
+
+ devices.append(USBDevice(
+ vendor_id=printer.idVendor,
+ product_id=printer.idProduct,
+ serial=serial,
+ model=model,
+ interface=interface_num,
+ manufacturer=manufacturer,
+ product=product,
+ device_path=device_path
+ ))
+
+ except Exception as e:
+ print(f"WARNING: Error processing USB device: {e}", file=sys.stderr)
+ continue
+
+ return devices
+
+
+__all__ = ['DarwinUSBBackend']
diff --git a/cups/backend/usb/linux.py b/cups/backend/usb/linux.py
new file mode 100644
index 0000000..eef3d4e
--- /dev/null
+++ b/cups/backend/usb/linux.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+Linux USB implementation using PyUSB.
+"""
+
+import sys
+from typing import List
+
+try:
+ import usb.core
+ import usb.util
+ PYUSB_AVAILABLE = True
+except ImportError:
+ PYUSB_AVAILABLE = False
+
+from usb.base import USBBackend, USBDevice
+
+
+class FindPrinterClass:
+ """Custom matcher for USB printer class devices."""
+
+ PRINTER_CLASS = 7 # USB Printer class
+
+ def __init__(self):
+ pass
+
+ def __call__(self, device):
+ """Check if device is a printer class device."""
+ # Check device class
+ if device.bDeviceClass == self.PRINTER_CLASS:
+ return True
+
+ # Check interface classes
+ for cfg in device:
+ intf = usb.util.find_descriptor(
+ cfg,
+ bInterfaceClass=self.PRINTER_CLASS
+ )
+ if intf is not None:
+ return True
+
+ return False
+
+
+class LinuxUSBBackend(USBBackend):
+ """Linux USB backend using PyUSB."""
+
+ def __init__(self):
+ """Initialize the Linux USB backend."""
+ if not PYUSB_AVAILABLE:
+ raise ImportError(
+ "PyUSB not available. Install with: pip install pyusb"
+ )
+
+ def discover_devices(self) -> List[USBDevice]:
+ """
+ Discover connected Phomemo USB printers.
+
+ Returns:
+ List of discovered USBDevice objects
+ """
+ devices = []
+
+ try:
+ printers = usb.core.find(
+ find_all=True,
+ custom_match=FindPrinterClass(),
+ idVendor=self.PHOMEMO_VENDOR_ID
+ )
+ except Exception as e:
+ print(f"WARNING: USB discovery failed: {e}", file=sys.stderr)
+ return devices
+
+ for printer in printers:
+ try:
+ # Find printer interface
+ interface_num = None
+ for cfg in printer:
+ intf = usb.util.find_descriptor(
+ cfg,
+ bInterfaceClass=FindPrinterClass.PRINTER_CLASS
+ )
+ if intf is not None:
+ interface_num = intf.bInterfaceNumber
+ break
+
+ if interface_num is None:
+ continue
+
+ # Get device strings
+ try:
+ usb.util.get_langids(printer)
+ serial = usb.util.get_string(printer, printer.iSerialNumber)
+ manufacturer = printer.manufacturer or 'Unknown'
+ product = printer.product or 'Unknown'
+ except Exception:
+ serial = 'Unknown'
+ manufacturer = 'Unknown'
+ product = 'Unknown'
+
+ model = self.get_model_name(printer.idProduct)
+
+ devices.append(USBDevice(
+ vendor_id=printer.idVendor,
+ product_id=printer.idProduct,
+ serial=serial,
+ model=model,
+ interface=interface_num,
+ manufacturer=manufacturer,
+ product=product,
+ device_path=f'/dev/usb/lp0' # Linux USB printer path
+ ))
+
+ except Exception as e:
+ print(f"WARNING: Error processing USB device: {e}", file=sys.stderr)
+ continue
+
+ return devices
+
+
+__all__ = ['LinuxUSBBackend']
diff --git a/cups/filter/rastertopm02_t02.py b/cups/filter/rastertopm02_t02.py
index 6ee62a0..d0aadad 100755
--- a/cups/filter/rastertopm02_t02.py
+++ b/cups/filter/rastertopm02_t02.py
@@ -85,7 +85,7 @@ def print_raster(file, image, line, lines = 0xff, mode = 0):
file.write(lines.to_bytes(2, 'little'))
# bit image
block = image.crop((0, line, image.width, line + lines))
- stdout.write(block.tobytes())
+ file.write(block.tobytes())
return
def print_and_feed(file, lines = 1):
diff --git a/cups/filter/rastertopm110.c b/cups/filter/rastertopm110.c
new file mode 100644
index 0000000..373538e
--- /dev/null
+++ b/cups/filter/rastertopm110.c
@@ -0,0 +1,231 @@
+/*
+ * rastertopm110.c - CUPS filter for Phomemo M110/M220 thermal printers
+ *
+ * Compile: gcc -o rastertopm110 rastertopm110.c -lcups -lcupsimage
+ * Install: sudo cp rastertopm110 /usr/libexec/cups/filter/
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* Printer command bytes */
+#define ESC 0x1b
+#define GS 0x1d
+
+/* Debug logging to stderr (captured by CUPS) */
+#define DEBUG(...) fprintf(stderr, "DEBUG: " __VA_ARGS__)
+
+/*
+ * Send printer initialization commands
+ */
+static void
+send_header(int media_type)
+{
+ unsigned char cmd[4];
+
+ /* Set speed: ESC N 0x0d */
+ cmd[0] = ESC;
+ cmd[1] = 0x4e;
+ cmd[2] = 0x0d;
+ cmd[3] = 5; /* speed = 5 */
+ fwrite(cmd, 1, 4, stdout);
+
+ /* Set density: ESC N 0x04 */
+ cmd[0] = ESC;
+ cmd[1] = 0x4e;
+ cmd[2] = 0x04;
+ cmd[3] = 10; /* density = 10 */
+ fwrite(cmd, 1, 4, stdout);
+
+ /* Set media type: 0x1f 0x11 */
+ cmd[0] = 0x1f;
+ cmd[1] = 0x11;
+ cmd[2] = (unsigned char)media_type;
+ fwrite(cmd, 1, 3, stdout);
+}
+
+/*
+ * Send raster image data
+ */
+static void
+send_raster(unsigned char *data, int width, int height)
+{
+ unsigned char cmd[6];
+ int width_bytes = (width + 7) / 8;
+
+ /* GS v 0 */
+ cmd[0] = GS;
+ cmd[1] = 'v';
+ cmd[2] = '0';
+ cmd[3] = 0; /* mode = 0 (normal) */
+ fwrite(cmd, 1, 4, stdout);
+
+ /* Width in bytes (little-endian) */
+ cmd[0] = width_bytes & 0xff;
+ cmd[1] = (width_bytes >> 8) & 0xff;
+ fwrite(cmd, 1, 2, stdout);
+
+ /* Height in lines (little-endian) */
+ cmd[0] = height & 0xff;
+ cmd[1] = (height >> 8) & 0xff;
+ fwrite(cmd, 1, 2, stdout);
+
+ /* Send image data */
+ fwrite(data, 1, width_bytes * height, stdout);
+}
+
+/*
+ * Send footer commands
+ */
+static void
+send_footer(void)
+{
+ unsigned char cmd[4];
+
+ cmd[0] = 0x1f;
+ cmd[1] = 0xf0;
+ cmd[2] = 0x05;
+ cmd[3] = 0x00;
+ fwrite(cmd, 1, 4, stdout);
+
+ cmd[0] = 0x1f;
+ cmd[1] = 0xf0;
+ cmd[2] = 0x03;
+ cmd[3] = 0x00;
+ fwrite(cmd, 1, 4, stdout);
+}
+
+/*
+ * Convert 8-bit grayscale line to 1-bit (inverted for thermal printer)
+ */
+static void
+convert_line_to_1bit(unsigned char *src, unsigned char *dst, int width)
+{
+ int x, byte_idx, bit_idx;
+ int width_bytes = (width + 7) / 8;
+
+ memset(dst, 0, width_bytes);
+
+ for (x = 0; x < width; x++) {
+ byte_idx = x / 8;
+ bit_idx = 7 - (x % 8);
+
+ /* Invert: dark pixels (low value) become 1 (print), light pixels become 0 */
+ if (src[x] < 128) {
+ dst[byte_idx] |= (1 << bit_idx);
+ }
+ }
+}
+
+/*
+ * Main filter function
+ */
+int
+main(int argc, char *argv[])
+{
+ cups_raster_t *ras;
+ cups_page_header2_t header;
+ unsigned char *line_in = NULL;
+ unsigned char *line_out = NULL;
+ unsigned char *page_data = NULL;
+ int page = 0;
+ int y;
+ int width_bytes;
+ int fd;
+
+ DEBUG("rastertopm110 filter starting\n");
+ DEBUG("argc=%d\n", argc);
+
+ /* Check arguments */
+ if (argc < 6 || argc > 7) {
+ fprintf(stderr, "Usage: %s job user title copies options [file]\n", argv[0]);
+ return 1;
+ }
+
+ /* Open raster stream */
+ if (argc == 7) {
+ /* Read from file */
+ if ((fd = open(argv[6], O_RDONLY)) < 0) {
+ perror("ERROR: Unable to open input file");
+ return 1;
+ }
+ ras = cupsRasterOpen(fd, CUPS_RASTER_READ);
+ } else {
+ /* Read from stdin */
+ ras = cupsRasterOpen(0, CUPS_RASTER_READ);
+ }
+
+ if (!ras) {
+ fprintf(stderr, "ERROR: Unable to open raster stream\n");
+ return 1;
+ }
+
+ DEBUG("Raster stream opened\n");
+
+ /* Process pages */
+ while (cupsRasterReadHeader2(ras, &header)) {
+ page++;
+ DEBUG("Page %d: %dx%d pixels, %d bpp, colorspace=%d, mediatype=%d\n",
+ page, header.cupsWidth, header.cupsHeight,
+ header.cupsBitsPerPixel, header.cupsColorSpace,
+ header.cupsMediaType);
+
+ if (header.cupsWidth == 0 || header.cupsHeight == 0) {
+ DEBUG("Empty page, skipping\n");
+ continue;
+ }
+
+ /* Allocate buffers */
+ width_bytes = (header.cupsWidth + 7) / 8;
+ line_in = malloc(header.cupsBytesPerLine);
+ line_out = malloc(width_bytes);
+ page_data = malloc(width_bytes * header.cupsHeight);
+
+ if (!line_in || !line_out || !page_data) {
+ fprintf(stderr, "ERROR: Unable to allocate memory\n");
+ return 1;
+ }
+
+ /* Read and convert each line */
+ for (y = 0; y < header.cupsHeight; y++) {
+ if (cupsRasterReadPixels(ras, line_in, header.cupsBytesPerLine) == 0) {
+ DEBUG("Error reading line %d\n", y);
+ break;
+ }
+
+ /* Convert to 1-bit */
+ convert_line_to_1bit(line_in, line_out, header.cupsWidth);
+
+ /* Copy to page buffer */
+ memcpy(page_data + (y * width_bytes), line_out, width_bytes);
+ }
+
+ DEBUG("Read %d lines, sending to printer\n", y);
+
+ /* Send to printer */
+ send_header(header.cupsMediaType ? header.cupsMediaType : 10);
+ send_raster(page_data, header.cupsWidth, header.cupsHeight);
+ send_footer();
+
+ fflush(stdout);
+
+ DEBUG("Page %d sent\n", page);
+
+ /* Free buffers */
+ free(line_in);
+ free(line_out);
+ free(page_data);
+ line_in = line_out = page_data = NULL;
+ }
+
+ cupsRasterClose(ras);
+
+ DEBUG("Filter complete, processed %d pages\n", page);
+
+ return 0;
+}
diff --git a/cups/filter/rastertopm110.py b/cups/filter/rastertopm110.py
index 6b010dc..62365da 100755
--- a/cups/filter/rastertopm110.py
+++ b/cups/filter/rastertopm110.py
@@ -92,7 +92,7 @@ def print_raster(file, image, line, lines = 0xff, mode = 0):
file.write(lines.to_bytes(2, 'little'))
# bit image
block = image.crop((0, line, image.width, line + lines))
- stdout.write(block.tobytes())
+ file.write(block.tobytes())
return
def print_footer(file):
diff --git a/cups/ppd/Phomemo-D30.ppd b/cups/ppd/Phomemo-D30.ppd
new file mode 100644
index 0000000..a7462ee
--- /dev/null
+++ b/cups/ppd/Phomemo-D30.ppd
@@ -0,0 +1,74 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for D30 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "2.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-D30.ppd"
+*Product: "(D30)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo D30"
+*ShortNickName: "Phomemo D30"
+*NickName: "Phomemo D30"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopd30"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w40h12
+*PageSize w40h12/Label 12mmx40mm: "<>setpagedevice"
+*PageSize w30h14/Label 14mmx30mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w40h12
+*PageRegion w40h12/Label 12mmx40mm: "<>setpagedevice"
+*PageRegion w30h14/Label 14mmx30mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w40h12
+*ImageableArea w40h12/Label 12mmx40mm: "2.834645748138 0 110.55118560791 34.015747070312"
+*ImageableArea w30h14/Label 14mmx30mm: "2.834645748138 0 82.204727172852 39.685039520264"
+*DefaultPaperDimension: w40h12
+*PaperDimension w40h12/Label 12mmx40mm: "113.385833740234 34.015747070312"
+*PaperDimension w30h14/Label 14mmx30mm: "85.039375305176 39.685039520264"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *FeedLines/Feed Lines for Tearing: PickOne
+*OrderDependency: 20 DocumentSetup *FeedLines
+*DefaultFeedLines: Default
+*FeedLines Default/Default (2): "<>setpagedevice"
+*CloseUI: *FeedLines
+*CustomFeedLines True: "<>setpagedevice"
+*ParamCustomFeedLines Lines/Lines: 1 int 0 20
+*DefaultFont: Courier
+*% End of Phomemo-D30.ppd, 03020 bytes.
diff --git a/cups/ppd/Phomemo-M02.ppd b/cups/ppd/Phomemo-M02.ppd
new file mode 100644
index 0000000..31ee1b1
--- /dev/null
+++ b/cups/ppd/Phomemo-M02.ppd
@@ -0,0 +1,138 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for M02 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "2.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-M02.ppd"
+*Product: "(M02)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo M02"
+*ShortNickName: "Phomemo M02"
+*NickName: "Phomemo M02"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm02_t02"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w50h60
+*PageSize w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageSize w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageSize w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageSize w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageSize w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageSize w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageSize w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageSize w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageSize w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w50h60
+*PageRegion w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageRegion w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageRegion w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageRegion w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageRegion w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageRegion w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageRegion w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageRegion w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageRegion w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w50h60
+*ImageableArea w50h10/Label 50mmx10mm: "2.834645748138 0 138.897644042969 28.346458435059"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h25/Label 50mmx25mm: "2.834645748138 0 138.897644042969 70.866142272949"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h40/Label 50mmx40mm: "2.834645748138 0 138.897644042969 113.385833740234"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h60/Label 50mmx60mm: "2.834645748138 0 138.897644042969 170.078750610352"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h75/Label 50mmx75mm: "2.834645748138 0 138.897644042969 212.598434448242"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*ImageableArea w50h90/Label 50mmx90mm: "2.834645748138 0 138.897644042969 255.118118286133"
+*ImageableArea w50h100/Label 50mmx100mm: "2.834645748138 0 138.897644042969 283.464569091797"
+*ImageableArea w50h110/Label 50mmx110mm: "2.834645748138 0 138.897644042969 311.81103515625"
+*ImageableArea w50h120/Label 50mmx120mm: "2.834645748138 0 138.897644042969 340.157501220703"
+*ImageableArea w50h125/Label 50mmx125mm: "2.834645748138 0 138.897644042969 354.330718994141"
+*ImageableArea w50h130/Label 50mmx130mm: "2.834645748138 0 138.897644042969 368.503936767578"
+*ImageableArea w50h140/Label 50mmx140mm: "2.834645748138 0 138.897644042969 396.850402832031"
+*ImageableArea w50h150/Label 50mmx150mm: "2.834645748138 0 138.897644042969 425.196868896484"
+*DefaultPaperDimension: w50h60
+*PaperDimension w50h10/Label 50mmx10mm: "141.732284545898 28.346458435059"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h25/Label 50mmx25mm: "141.732284545898 70.866142272949"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h40/Label 50mmx40mm: "141.732284545898 113.385833740234"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h60/Label 50mmx60mm: "141.732284545898 170.078750610352"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h75/Label 50mmx75mm: "141.732284545898 212.598434448242"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*PaperDimension w50h90/Label 50mmx90mm: "141.732284545898 255.118118286133"
+*PaperDimension w50h100/Label 50mmx100mm: "141.732284545898 283.464569091797"
+*PaperDimension w50h110/Label 50mmx110mm: "141.732284545898 311.81103515625"
+*PaperDimension w50h120/Label 50mmx120mm: "141.732284545898 340.157501220703"
+*PaperDimension w50h125/Label 50mmx125mm: "141.732284545898 354.330718994141"
+*PaperDimension w50h130/Label 50mmx130mm: "141.732284545898 368.503936767578"
+*PaperDimension w50h140/Label 50mmx140mm: "141.732284545898 396.850402832031"
+*PaperDimension w50h150/Label 50mmx150mm: "141.732284545898 425.196868896484"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *FeedLines/Feed Lines for Tearing: PickOne
+*OrderDependency: 20 DocumentSetup *FeedLines
+*DefaultFeedLines: Default
+*FeedLines Default/Default (2): "<>setpagedevice"
+*CloseUI: *FeedLines
+*CustomFeedLines True: "<>setpagedevice"
+*ParamCustomFeedLines Lines/Lines: 1 int 0 20
+*DefaultFont: Courier
+*% End of Phomemo-M02.ppd, 08643 bytes.
diff --git a/cups/ppd/Phomemo-M02Pro.ppd b/cups/ppd/Phomemo-M02Pro.ppd
new file mode 100644
index 0000000..d66ac4b
--- /dev/null
+++ b/cups/ppd/Phomemo-M02Pro.ppd
@@ -0,0 +1,138 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for M02 Pro with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "2.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-M02Pro.ppd"
+*Product: "(M02 Pro)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo M02 Pro"
+*ShortNickName: "Phomemo M02 Pro"
+*NickName: "Phomemo M02 Pro"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm02_t02"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w50h60
+*PageSize w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageSize w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageSize w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageSize w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageSize w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageSize w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageSize w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageSize w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageSize w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w50h60
+*PageRegion w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageRegion w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageRegion w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageRegion w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageRegion w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageRegion w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageRegion w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageRegion w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageRegion w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w50h60
+*ImageableArea w50h10/Label 50mmx10mm: "2.834645748138 0 138.897644042969 28.346458435059"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h25/Label 50mmx25mm: "2.834645748138 0 138.897644042969 70.866142272949"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h40/Label 50mmx40mm: "2.834645748138 0 138.897644042969 113.385833740234"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h60/Label 50mmx60mm: "2.834645748138 0 138.897644042969 170.078750610352"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h75/Label 50mmx75mm: "2.834645748138 0 138.897644042969 212.598434448242"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*ImageableArea w50h90/Label 50mmx90mm: "2.834645748138 0 138.897644042969 255.118118286133"
+*ImageableArea w50h100/Label 50mmx100mm: "2.834645748138 0 138.897644042969 283.464569091797"
+*ImageableArea w50h110/Label 50mmx110mm: "2.834645748138 0 138.897644042969 311.81103515625"
+*ImageableArea w50h120/Label 50mmx120mm: "2.834645748138 0 138.897644042969 340.157501220703"
+*ImageableArea w50h125/Label 50mmx125mm: "2.834645748138 0 138.897644042969 354.330718994141"
+*ImageableArea w50h130/Label 50mmx130mm: "2.834645748138 0 138.897644042969 368.503936767578"
+*ImageableArea w50h140/Label 50mmx140mm: "2.834645748138 0 138.897644042969 396.850402832031"
+*ImageableArea w50h150/Label 50mmx150mm: "2.834645748138 0 138.897644042969 425.196868896484"
+*DefaultPaperDimension: w50h60
+*PaperDimension w50h10/Label 50mmx10mm: "141.732284545898 28.346458435059"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h25/Label 50mmx25mm: "141.732284545898 70.866142272949"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h40/Label 50mmx40mm: "141.732284545898 113.385833740234"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h60/Label 50mmx60mm: "141.732284545898 170.078750610352"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h75/Label 50mmx75mm: "141.732284545898 212.598434448242"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*PaperDimension w50h90/Label 50mmx90mm: "141.732284545898 255.118118286133"
+*PaperDimension w50h100/Label 50mmx100mm: "141.732284545898 283.464569091797"
+*PaperDimension w50h110/Label 50mmx110mm: "141.732284545898 311.81103515625"
+*PaperDimension w50h120/Label 50mmx120mm: "141.732284545898 340.157501220703"
+*PaperDimension w50h125/Label 50mmx125mm: "141.732284545898 354.330718994141"
+*PaperDimension w50h130/Label 50mmx130mm: "141.732284545898 368.503936767578"
+*PaperDimension w50h140/Label 50mmx140mm: "141.732284545898 396.850402832031"
+*PaperDimension w50h150/Label 50mmx150mm: "141.732284545898 425.196868896484"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 300dpi
+*Resolution 300dpi/300dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *FeedLines/Feed Lines for Tearing: PickOne
+*OrderDependency: 20 DocumentSetup *FeedLines
+*DefaultFeedLines: Default
+*FeedLines Default/Default (2): "<>setpagedevice"
+*CloseUI: *FeedLines
+*CustomFeedLines True: "<>setpagedevice"
+*ParamCustomFeedLines Lines/Lines: 1 int 0 20
+*DefaultFont: Courier
+*% End of Phomemo-M02Pro.ppd, 08669 bytes.
diff --git a/cups/ppd/Phomemo-M110.ppd b/cups/ppd/Phomemo-M110.ppd
new file mode 100644
index 0000000..c6d68d7
--- /dev/null
+++ b/cups/ppd/Phomemo-M110.ppd
@@ -0,0 +1,150 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for M110 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "1.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-M110.ppd"
+*Product: "(M110)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo M110"
+*ShortNickName: "Phomemo M110"
+*NickName: "Phomemo M110"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm110"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w40h30
+*PageSize w20h100/Label 20mmx100mm: "<>setpagedevice"
+*PageSize w20h10/Label 20mmx10mm: "<>setpagedevice"
+*PageSize w20h20/Label 20mmx20mm: "<>setpagedevice"
+*PageSize w25h10/Label 25mmx10mm: "<>setpagedevice"
+*PageSize w25h30/Label 25mmx30mm: "<>setpagedevice"
+*PageSize w25h38/Label 25mmx38mm: "<>setpagedevice"
+*PageSize w30h20/Label 30mmx20mm: "<>setpagedevice"
+*PageSize w30h25/Label 30mmx25mm: "<>setpagedevice"
+*PageSize w30h30/Label 30mmx30mm: "<>setpagedevice"
+*PageSize w35h15/Label 35mmx15mm: "<>setpagedevice"
+*PageSize w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageSize w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageSize w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageSize w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageSize w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageSize w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w40h30
+*PageRegion w20h100/Label 20mmx100mm: "<>setpagedevice"
+*PageRegion w20h10/Label 20mmx10mm: "<>setpagedevice"
+*PageRegion w20h20/Label 20mmx20mm: "<>setpagedevice"
+*PageRegion w25h10/Label 25mmx10mm: "<>setpagedevice"
+*PageRegion w25h30/Label 25mmx30mm: "<>setpagedevice"
+*PageRegion w25h38/Label 25mmx38mm: "<>setpagedevice"
+*PageRegion w30h20/Label 30mmx20mm: "<>setpagedevice"
+*PageRegion w30h25/Label 30mmx25mm: "<>setpagedevice"
+*PageRegion w30h30/Label 30mmx30mm: "<>setpagedevice"
+*PageRegion w35h15/Label 35mmx15mm: "<>setpagedevice"
+*PageRegion w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageRegion w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageRegion w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageRegion w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageRegion w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageRegion w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w40h30
+*ImageableArea w20h100/Label 20mmx100mm: "2.834645748138 0 53.85827255249 283.464569091797"
+*ImageableArea w20h10/Label 20mmx10mm: "2.834645748138 0 53.85827255249 28.346458435059"
+*ImageableArea w20h20/Label 20mmx20mm: "2.834645748138 0 53.85827255249 56.692916870117"
+*ImageableArea w25h10/Label 25mmx10mm: "2.834645748138 0 68.031494140625 28.346458435059"
+*ImageableArea w25h30/Label 25mmx30mm: "2.834645748138 0 68.031494140625 85.039375305176"
+*ImageableArea w25h38/Label 25mmx38mm: "2.834645748138 0 68.031494140625 107.716537475586"
+*ImageableArea w30h20/Label 30mmx20mm: "2.834645748138 0 82.204727172852 56.692916870117"
+*ImageableArea w30h25/Label 30mmx25mm: "2.834645748138 0 82.204727172852 70.866142272949"
+*ImageableArea w30h30/Label 30mmx30mm: "2.834645748138 0 82.204727172852 85.039375305176"
+*ImageableArea w35h15/Label 35mmx15mm: "2.834645748138 0 96.377952575684 42.519687652588"
+*ImageableArea w40h20/Label 40mmx20mm: "2.834645748138 0 110.55118560791 56.692916870117"
+*ImageableArea w40h30/Label 40mmx30mm: "2.834645748138 0 110.55118560791 85.039375305176"
+*ImageableArea w40h40/Label 40mmx40mm: "2.834645748138 0 110.55118560791 113.385833740234"
+*ImageableArea w40h60/Label 40mmx60mm: "2.834645748138 0 110.55118560791 170.078750610352"
+*ImageableArea w40h80/Label 40mmx80mm: "2.834645748138 0 110.55118560791 226.771667480469"
+*ImageableArea w45h60/Label 45mmx60mm: "2.834645748138 0 124.724411010742 170.078750610352"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*DefaultPaperDimension: w40h30
+*PaperDimension w20h100/Label 20mmx100mm: "56.692916870117 283.464569091797"
+*PaperDimension w20h10/Label 20mmx10mm: "56.692916870117 28.346458435059"
+*PaperDimension w20h20/Label 20mmx20mm: "56.692916870117 56.692916870117"
+*PaperDimension w25h10/Label 25mmx10mm: "70.866142272949 28.346458435059"
+*PaperDimension w25h30/Label 25mmx30mm: "70.866142272949 85.039375305176"
+*PaperDimension w25h38/Label 25mmx38mm: "70.866142272949 107.716537475586"
+*PaperDimension w30h20/Label 30mmx20mm: "85.039375305176 56.692916870117"
+*PaperDimension w30h25/Label 30mmx25mm: "85.039375305176 70.866142272949"
+*PaperDimension w30h30/Label 30mmx30mm: "85.039375305176 85.039375305176"
+*PaperDimension w35h15/Label 35mmx15mm: "99.212600708008 42.519687652588"
+*PaperDimension w40h20/Label 40mmx20mm: "113.385833740234 56.692916870117"
+*PaperDimension w40h30/Label 40mmx30mm: "113.385833740234 85.039375305176"
+*PaperDimension w40h40/Label 40mmx40mm: "113.385833740234 113.385833740234"
+*PaperDimension w40h60/Label 40mmx60mm: "113.385833740234 170.078750610352"
+*PaperDimension w40h80/Label 40mmx80mm: "113.385833740234 226.771667480469"
+*PaperDimension w45h60/Label 45mmx60mm: "127.559059143066 170.078750610352"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *MediaType/Media Type: PickOne
+*OrderDependency: 10 AnySetup *MediaType
+*DefaultMediaType: LabelWithGaps
+*MediaType LabelWithGaps/Label With Gaps: "<>setpagedevice"
+*MediaType Continuous/Continuous: "<>setpagedevice"
+*MediaType LabelWithMarks/Label With Marks: "<>setpagedevice"
+*CloseUI: *MediaType
+*DefaultFont: Courier
+*% End of Phomemo-M110.ppd, 09673 bytes.
diff --git a/cups/ppd/Phomemo-M220.ppd b/cups/ppd/Phomemo-M220.ppd
new file mode 100644
index 0000000..4af168c
--- /dev/null
+++ b/cups/ppd/Phomemo-M220.ppd
@@ -0,0 +1,154 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for M220 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "1.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-M220.ppd"
+*Product: "(M220)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo M220"
+*ShortNickName: "Phomemo M220"
+*NickName: "Phomemo M220"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm110"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w40h30
+*PageSize w20h100/Label 20mmx100mm: "<>setpagedevice"
+*PageSize w20h10/Label 20mmx10mm: "<>setpagedevice"
+*PageSize w20h20/Label 20mmx20mm: "<>setpagedevice"
+*PageSize w25h10/Label 25mmx10mm: "<>setpagedevice"
+*PageSize w25h30/Label 25mmx30mm: "<>setpagedevice"
+*PageSize w25h38/Label 25mmx38mm: "<>setpagedevice"
+*PageSize w30h20/Label 30mmx20mm: "<>setpagedevice"
+*PageSize w30h25/Label 30mmx25mm: "<>setpagedevice"
+*PageSize w30h30/Label 30mmx30mm: "<>setpagedevice"
+*PageSize w35h15/Label 35mmx15mm: "<>setpagedevice"
+*PageSize w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageSize w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageSize w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageSize w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageSize w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageSize w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageSize w70h80/Label 70mmx80mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w40h30
+*PageRegion w20h100/Label 20mmx100mm: "<>setpagedevice"
+*PageRegion w20h10/Label 20mmx10mm: "<>setpagedevice"
+*PageRegion w20h20/Label 20mmx20mm: "<>setpagedevice"
+*PageRegion w25h10/Label 25mmx10mm: "<>setpagedevice"
+*PageRegion w25h30/Label 25mmx30mm: "<>setpagedevice"
+*PageRegion w25h38/Label 25mmx38mm: "<>setpagedevice"
+*PageRegion w30h20/Label 30mmx20mm: "<>setpagedevice"
+*PageRegion w30h25/Label 30mmx25mm: "<>setpagedevice"
+*PageRegion w30h30/Label 30mmx30mm: "<>setpagedevice"
+*PageRegion w35h15/Label 35mmx15mm: "<>setpagedevice"
+*PageRegion w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageRegion w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageRegion w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageRegion w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageRegion w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageRegion w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageRegion w70h80/Label 70mmx80mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w40h30
+*ImageableArea w20h100/Label 20mmx100mm: "2.834645748138 0 53.85827255249 283.464569091797"
+*ImageableArea w20h10/Label 20mmx10mm: "2.834645748138 0 53.85827255249 28.346458435059"
+*ImageableArea w20h20/Label 20mmx20mm: "2.834645748138 0 53.85827255249 56.692916870117"
+*ImageableArea w25h10/Label 25mmx10mm: "2.834645748138 0 68.031494140625 28.346458435059"
+*ImageableArea w25h30/Label 25mmx30mm: "2.834645748138 0 68.031494140625 85.039375305176"
+*ImageableArea w25h38/Label 25mmx38mm: "2.834645748138 0 68.031494140625 107.716537475586"
+*ImageableArea w30h20/Label 30mmx20mm: "2.834645748138 0 82.204727172852 56.692916870117"
+*ImageableArea w30h25/Label 30mmx25mm: "2.834645748138 0 82.204727172852 70.866142272949"
+*ImageableArea w30h30/Label 30mmx30mm: "2.834645748138 0 82.204727172852 85.039375305176"
+*ImageableArea w35h15/Label 35mmx15mm: "2.834645748138 0 96.377952575684 42.519687652588"
+*ImageableArea w40h20/Label 40mmx20mm: "2.834645748138 0 110.55118560791 56.692916870117"
+*ImageableArea w40h30/Label 40mmx30mm: "2.834645748138 0 110.55118560791 85.039375305176"
+*ImageableArea w40h40/Label 40mmx40mm: "2.834645748138 0 110.55118560791 113.385833740234"
+*ImageableArea w40h60/Label 40mmx60mm: "2.834645748138 0 110.55118560791 170.078750610352"
+*ImageableArea w40h80/Label 40mmx80mm: "2.834645748138 0 110.55118560791 226.771667480469"
+*ImageableArea w45h60/Label 45mmx60mm: "2.834645748138 0 124.724411010742 170.078750610352"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*ImageableArea w70h80/Label 70mmx80mm: "2.834645748138 0 195.590560913086 226.771667480469"
+*DefaultPaperDimension: w40h30
+*PaperDimension w20h100/Label 20mmx100mm: "56.692916870117 283.464569091797"
+*PaperDimension w20h10/Label 20mmx10mm: "56.692916870117 28.346458435059"
+*PaperDimension w20h20/Label 20mmx20mm: "56.692916870117 56.692916870117"
+*PaperDimension w25h10/Label 25mmx10mm: "70.866142272949 28.346458435059"
+*PaperDimension w25h30/Label 25mmx30mm: "70.866142272949 85.039375305176"
+*PaperDimension w25h38/Label 25mmx38mm: "70.866142272949 107.716537475586"
+*PaperDimension w30h20/Label 30mmx20mm: "85.039375305176 56.692916870117"
+*PaperDimension w30h25/Label 30mmx25mm: "85.039375305176 70.866142272949"
+*PaperDimension w30h30/Label 30mmx30mm: "85.039375305176 85.039375305176"
+*PaperDimension w35h15/Label 35mmx15mm: "99.212600708008 42.519687652588"
+*PaperDimension w40h20/Label 40mmx20mm: "113.385833740234 56.692916870117"
+*PaperDimension w40h30/Label 40mmx30mm: "113.385833740234 85.039375305176"
+*PaperDimension w40h40/Label 40mmx40mm: "113.385833740234 113.385833740234"
+*PaperDimension w40h60/Label 40mmx60mm: "113.385833740234 170.078750610352"
+*PaperDimension w40h80/Label 40mmx80mm: "113.385833740234 226.771667480469"
+*PaperDimension w45h60/Label 45mmx60mm: "127.559059143066 170.078750610352"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*PaperDimension w70h80/Label 70mmx80mm: "198.425201416016 226.771667480469"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *MediaType/Media Type: PickOne
+*OrderDependency: 10 AnySetup *MediaType
+*DefaultMediaType: LabelWithGaps
+*MediaType LabelWithGaps/Label With Gaps: "<>setpagedevice"
+*MediaType Continuous/Continuous: "<>setpagedevice"
+*MediaType LabelWithMarks/Label With Marks: "<>setpagedevice"
+*CloseUI: *MediaType
+*DefaultFont: Courier
+*% End of Phomemo-M220.ppd, 10021 bytes.
diff --git a/cups/ppd/Phomemo-M421.ppd b/cups/ppd/Phomemo-M421.ppd
new file mode 100644
index 0000000..857fc8d
--- /dev/null
+++ b/cups/ppd/Phomemo-M421.ppd
@@ -0,0 +1,170 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for M421 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "1.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-M421.ppd"
+*Product: "(M421)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo M421"
+*ShortNickName: "Phomemo M421"
+*NickName: "Phomemo M421"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm110"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w4h6
+*PageSize w40h15/Label 40x15mm: "<>setpagedevice"
+*PageSize w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageSize w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageSize w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageSize w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageSize w40h70/Label 40x70mm: "<>setpagedevice"
+*PageSize w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageSize w45h15/Label 45x15mm: "<>setpagedevice"
+*PageSize w45h20/Label 45x20mm: "<>setpagedevice"
+*PageSize w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageSize w45h80/Label 45x80mm: "<>setpagedevice"
+*PageSize w50h15/Label 50x15mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageSize w60h40/Label 60x40mm: "<>setpagedevice"
+*PageSize w60h60/Label 60x60mm: "<>setpagedevice"
+*PageSize w60h80/Label 60x80mm: "<>setpagedevice"
+*PageSize w60h86/Label 60x86mm: "<>setpagedevice"
+*PageSize w62h100/Label 62x100mm: "<>setpagedevice"
+*PageSize w70h40/Label 70x40mm: "<>setpagedevice"
+*PageSize w70h70/Label 70x70mm: "<>setpagedevice"
+*PageSize w70h80/Label 70mmx80mm: "<>setpagedevice"
+*PageSize w4h6/Label 4x6in: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w4h6
+*PageRegion w40h15/Label 40x15mm: "<>setpagedevice"
+*PageRegion w40h20/Label 40mmx20mm: "<>setpagedevice"
+*PageRegion w40h30/Label 40mmx30mm: "<>setpagedevice"
+*PageRegion w40h40/Label 40mmx40mm: "<>setpagedevice"
+*PageRegion w40h60/Label 40mmx60mm: "<>setpagedevice"
+*PageRegion w40h70/Label 40x70mm: "<>setpagedevice"
+*PageRegion w40h80/Label 40mmx80mm: "<>setpagedevice"
+*PageRegion w45h15/Label 45x15mm: "<>setpagedevice"
+*PageRegion w45h20/Label 45x20mm: "<>setpagedevice"
+*PageRegion w45h60/Label 45mmx60mm: "<>setpagedevice"
+*PageRegion w45h80/Label 45x80mm: "<>setpagedevice"
+*PageRegion w50h15/Label 50x15mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageRegion w60h40/Label 60x40mm: "<>setpagedevice"
+*PageRegion w60h60/Label 60x60mm: "<>setpagedevice"
+*PageRegion w60h80/Label 60x80mm: "<>setpagedevice"
+*PageRegion w60h86/Label 60x86mm: "<>setpagedevice"
+*PageRegion w62h100/Label 62x100mm: "<>setpagedevice"
+*PageRegion w70h40/Label 70x40mm: "<>setpagedevice"
+*PageRegion w70h70/Label 70x70mm: "<>setpagedevice"
+*PageRegion w70h80/Label 70mmx80mm: "<>setpagedevice"
+*PageRegion w4h6/Label 4x6in: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w4h6
+*ImageableArea w40h15/Label 40x15mm: "2.834645748138 0 110.55118560791 42.519687652588"
+*ImageableArea w40h20/Label 40mmx20mm: "2.834645748138 0 110.55118560791 56.692916870117"
+*ImageableArea w40h30/Label 40mmx30mm: "2.834645748138 0 110.55118560791 85.039375305176"
+*ImageableArea w40h40/Label 40mmx40mm: "2.834645748138 0 110.55118560791 113.385833740234"
+*ImageableArea w40h60/Label 40mmx60mm: "2.834645748138 0 110.55118560791 170.078750610352"
+*ImageableArea w40h70/Label 40x70mm: "2.834645748138 0 110.55118560791 198.425201416016"
+*ImageableArea w40h80/Label 40mmx80mm: "2.834645748138 0 110.55118560791 226.771667480469"
+*ImageableArea w45h15/Label 45x15mm: "2.834645748138 0 124.724411010742 42.519687652588"
+*ImageableArea w45h20/Label 45x20mm: "2.834645748138 0 124.724411010742 56.692916870117"
+*ImageableArea w45h60/Label 45mmx60mm: "2.834645748138 0 124.724411010742 170.078750610352"
+*ImageableArea w45h80/Label 45x80mm: "2.834645748138 0 124.724411010742 226.771667480469"
+*ImageableArea w50h15/Label 50x15mm: "2.834645748138 0 138.897644042969 42.519687652588"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*ImageableArea w60h40/Label 60x40mm: "2.834645748138 0 167.244110107422 113.385833740234"
+*ImageableArea w60h60/Label 60x60mm: "2.834645748138 0 167.244110107422 170.078750610352"
+*ImageableArea w60h80/Label 60x80mm: "2.834645748138 0 167.244110107422 226.771667480469"
+*ImageableArea w60h86/Label 60x86mm: "2.834645748138 0 167.244110107422 243.779541015625"
+*ImageableArea w62h100/Label 62x100mm: "2.834645748138 0 172.913391113281 283.464569091797"
+*ImageableArea w70h40/Label 70x40mm: "2.834645748138 0 195.590560913086 113.385833740234"
+*ImageableArea w70h70/Label 70x70mm: "2.834645748138 0 195.590560913086 198.425201416016"
+*ImageableArea w70h80/Label 70mmx80mm: "2.834645748138 0 195.590560913086 226.771667480469"
+*ImageableArea w4h6/Label 4x6in: "2.834645748138 0 285.165344238281 432"
+*DefaultPaperDimension: w4h6
+*PaperDimension w40h15/Label 40x15mm: "113.385833740234 42.519687652588"
+*PaperDimension w40h20/Label 40mmx20mm: "113.385833740234 56.692916870117"
+*PaperDimension w40h30/Label 40mmx30mm: "113.385833740234 85.039375305176"
+*PaperDimension w40h40/Label 40mmx40mm: "113.385833740234 113.385833740234"
+*PaperDimension w40h60/Label 40mmx60mm: "113.385833740234 170.078750610352"
+*PaperDimension w40h70/Label 40x70mm: "113.385833740234 198.425201416016"
+*PaperDimension w40h80/Label 40mmx80mm: "113.385833740234 226.771667480469"
+*PaperDimension w45h15/Label 45x15mm: "127.559059143066 42.519687652588"
+*PaperDimension w45h20/Label 45x20mm: "127.559059143066 56.692916870117"
+*PaperDimension w45h60/Label 45mmx60mm: "127.559059143066 170.078750610352"
+*PaperDimension w45h80/Label 45x80mm: "127.559059143066 226.771667480469"
+*PaperDimension w50h15/Label 50x15mm: "141.732284545898 42.519687652588"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*PaperDimension w60h40/Label 60x40mm: "170.078750610352 113.385833740234"
+*PaperDimension w60h60/Label 60x60mm: "170.078750610352 170.078750610352"
+*PaperDimension w60h80/Label 60x80mm: "170.078750610352 226.771667480469"
+*PaperDimension w60h86/Label 60x86mm: "170.078750610352 243.779541015625"
+*PaperDimension w62h100/Label 62x100mm: "175.748031616211 283.464569091797"
+*PaperDimension w70h40/Label 70x40mm: "198.425201416016 113.385833740234"
+*PaperDimension w70h70/Label 70x70mm: "198.425201416016 198.425201416016"
+*PaperDimension w70h80/Label 70mmx80mm: "198.425201416016 226.771667480469"
+*PaperDimension w4h6/Label 4x6in: "288 432"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *MediaType/Media Type: PickOne
+*OrderDependency: 10 AnySetup *MediaType
+*DefaultMediaType: LabelWithGaps
+*MediaType LabelWithGaps/Label With Gaps: "<>setpagedevice"
+*MediaType Continuous/Continuous: "<>setpagedevice"
+*MediaType LabelWithMarks/Label With Marks: "<>setpagedevice"
+*CloseUI: *MediaType
+*DefaultFont: Courier
+*% End of Phomemo-M421.ppd, 11295 bytes.
diff --git a/cups/ppd/Phomemo-T02.ppd b/cups/ppd/Phomemo-T02.ppd
new file mode 100644
index 0000000..d1c7cee
--- /dev/null
+++ b/cups/ppd/Phomemo-T02.ppd
@@ -0,0 +1,138 @@
+*PPD-Adobe: "4.3"
+*%%%% PPD file for T02 with CUPS.
+*%%%% Created by the CUPS PPD Compiler CUPS v2.3.4.
+*FormatVersion: "4.3"
+*FileVersion: "2.0"
+*LanguageVersion: English
+*LanguageEncoding: ISOLatin1
+*PCFileName: "Phomemo-T02.ppd"
+*Product: "(T02)"
+*Manufacturer: "Phomemo"
+*ModelName: "Phomemo T02"
+*ShortNickName: "Phomemo T02"
+*NickName: "Phomemo T02"
+*PSVersion: "(3010.000) 0"
+*LanguageLevel: "3"
+*ColorDevice: False
+*DefaultColorSpace: Gray
+*FileSystem: False
+*Throughput: "1"
+*LandscapeOrientation: Plus90
+*TTRasterizer: Type42
+*% Driver-defined attributes...
+*cupsSNMPSupplies: "false"
+*cupsVersion: 2.3
+*cupsModelNumber: 0
+*cupsManualCopies: False
+*cupsFilter: "application/vnd.cups-raster 100 rastertopm02_t02"
+*cupsLanguages: "en"
+*OpenUI *PageSize/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageSize
+*DefaultPageSize: w50h60
+*PageSize w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageSize w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageSize w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageSize w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageSize w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageSize w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageSize w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageSize w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageSize w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageSize w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageSize w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageSize w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageSize w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageSize w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageSize w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageSize w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageSize w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageSize w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageSize
+*OpenUI *PageRegion/Media Size: PickOne
+*OrderDependency: 10 AnySetup *PageRegion
+*DefaultPageRegion: w50h60
+*PageRegion w50h10/Label 50mmx10mm: "<>setpagedevice"
+*PageRegion w50h20/Label 50mmx20mm: "<>setpagedevice"
+*PageRegion w50h25/Label 50mmx25mm: "<>setpagedevice"
+*PageRegion w50h30/Label 50mmx30mm: "<>setpagedevice"
+*PageRegion w50h40/Label 50mmx40mm: "<>setpagedevice"
+*PageRegion w50h50/Label 50mmx50mm: "<>setpagedevice"
+*PageRegion w50h60/Label 50mmx60mm: "<>setpagedevice"
+*PageRegion w50h70/Label 50mmx70mm: "<>setpagedevice"
+*PageRegion w50h75/Label 50mmx75mm: "<>setpagedevice"
+*PageRegion w50h80/Label 50mmx80mm: "<>setpagedevice"
+*PageRegion w50h90/Label 50mmx90mm: "<>setpagedevice"
+*PageRegion w50h100/Label 50mmx100mm: "<>setpagedevice"
+*PageRegion w50h110/Label 50mmx110mm: "<>setpagedevice"
+*PageRegion w50h120/Label 50mmx120mm: "<>setpagedevice"
+*PageRegion w50h125/Label 50mmx125mm: "<>setpagedevice"
+*PageRegion w50h130/Label 50mmx130mm: "<>setpagedevice"
+*PageRegion w50h140/Label 50mmx140mm: "<>setpagedevice"
+*PageRegion w50h150/Label 50mmx150mm: "<>setpagedevice"
+*CloseUI: *PageRegion
+*DefaultImageableArea: w50h60
+*ImageableArea w50h10/Label 50mmx10mm: "2.834645748138 0 138.897644042969 28.346458435059"
+*ImageableArea w50h20/Label 50mmx20mm: "2.834645748138 0 138.897644042969 56.692916870117"
+*ImageableArea w50h25/Label 50mmx25mm: "2.834645748138 0 138.897644042969 70.866142272949"
+*ImageableArea w50h30/Label 50mmx30mm: "2.834645748138 0 138.897644042969 85.039375305176"
+*ImageableArea w50h40/Label 50mmx40mm: "2.834645748138 0 138.897644042969 113.385833740234"
+*ImageableArea w50h50/Label 50mmx50mm: "2.834645748138 0 138.897644042969 141.732284545898"
+*ImageableArea w50h60/Label 50mmx60mm: "2.834645748138 0 138.897644042969 170.078750610352"
+*ImageableArea w50h70/Label 50mmx70mm: "2.834645748138 0 138.897644042969 198.425201416016"
+*ImageableArea w50h75/Label 50mmx75mm: "2.834645748138 0 138.897644042969 212.598434448242"
+*ImageableArea w50h80/Label 50mmx80mm: "2.834645748138 0 138.897644042969 226.771667480469"
+*ImageableArea w50h90/Label 50mmx90mm: "2.834645748138 0 138.897644042969 255.118118286133"
+*ImageableArea w50h100/Label 50mmx100mm: "2.834645748138 0 138.897644042969 283.464569091797"
+*ImageableArea w50h110/Label 50mmx110mm: "2.834645748138 0 138.897644042969 311.81103515625"
+*ImageableArea w50h120/Label 50mmx120mm: "2.834645748138 0 138.897644042969 340.157501220703"
+*ImageableArea w50h125/Label 50mmx125mm: "2.834645748138 0 138.897644042969 354.330718994141"
+*ImageableArea w50h130/Label 50mmx130mm: "2.834645748138 0 138.897644042969 368.503936767578"
+*ImageableArea w50h140/Label 50mmx140mm: "2.834645748138 0 138.897644042969 396.850402832031"
+*ImageableArea w50h150/Label 50mmx150mm: "2.834645748138 0 138.897644042969 425.196868896484"
+*DefaultPaperDimension: w50h60
+*PaperDimension w50h10/Label 50mmx10mm: "141.732284545898 28.346458435059"
+*PaperDimension w50h20/Label 50mmx20mm: "141.732284545898 56.692916870117"
+*PaperDimension w50h25/Label 50mmx25mm: "141.732284545898 70.866142272949"
+*PaperDimension w50h30/Label 50mmx30mm: "141.732284545898 85.039375305176"
+*PaperDimension w50h40/Label 50mmx40mm: "141.732284545898 113.385833740234"
+*PaperDimension w50h50/Label 50mmx50mm: "141.732284545898 141.732284545898"
+*PaperDimension w50h60/Label 50mmx60mm: "141.732284545898 170.078750610352"
+*PaperDimension w50h70/Label 50mmx70mm: "141.732284545898 198.425201416016"
+*PaperDimension w50h75/Label 50mmx75mm: "141.732284545898 212.598434448242"
+*PaperDimension w50h80/Label 50mmx80mm: "141.732284545898 226.771667480469"
+*PaperDimension w50h90/Label 50mmx90mm: "141.732284545898 255.118118286133"
+*PaperDimension w50h100/Label 50mmx100mm: "141.732284545898 283.464569091797"
+*PaperDimension w50h110/Label 50mmx110mm: "141.732284545898 311.81103515625"
+*PaperDimension w50h120/Label 50mmx120mm: "141.732284545898 340.157501220703"
+*PaperDimension w50h125/Label 50mmx125mm: "141.732284545898 354.330718994141"
+*PaperDimension w50h130/Label 50mmx130mm: "141.732284545898 368.503936767578"
+*PaperDimension w50h140/Label 50mmx140mm: "141.732284545898 396.850402832031"
+*PaperDimension w50h150/Label 50mmx150mm: "141.732284545898 425.196868896484"
+*MaxMediaWidth: "0"
+*MaxMediaHeight: "0"
+*HWMargins: 2.834645748138 0 2.834645748138 0
+*CustomPageSize True: "pop pop pop <>setpagedevice"
+*ParamCustomPageSize Width: 1 points 0 0
+*ParamCustomPageSize Height: 2 points 0 0
+*ParamCustomPageSize WidthOffset: 3 points 0 0
+*ParamCustomPageSize HeightOffset: 4 points 0 0
+*ParamCustomPageSize Orientation: 5 int 0 0
+*OpenUI *Resolution/Resolution: PickOne
+*OrderDependency: 10 AnySetup *Resolution
+*DefaultResolution: 203dpi
+*Resolution 203dpi/203dpi: "<>setpagedevice"
+*CloseUI: *Resolution
+*OpenUI *ColorModel/Color Mode: PickOne
+*OrderDependency: 10 AnySetup *ColorModel
+*DefaultColorModel: Gray
+*ColorModel Gray/Grayscale: "<>setpagedevice"
+*CloseUI: *ColorModel
+*OpenUI *FeedLines/Feed Lines for Tearing: PickOne
+*OrderDependency: 20 DocumentSetup *FeedLines
+*DefaultFeedLines: Default
+*FeedLines Default/Default (4): "<>setpagedevice"
+*CloseUI: *FeedLines
+*CustomFeedLines True: "<>setpagedevice"
+*ParamCustomFeedLines Lines/Lines: 1 int 0 20
+*DefaultFont: Courier
+*% End of Phomemo-T02.ppd, 08643 bytes.
diff --git a/docs/README.MD b/docs/README.MD
new file mode 100644
index 0000000..c7ddb16
--- /dev/null
+++ b/docs/README.MD
@@ -0,0 +1,414 @@
+# Phomemo Tools Documentation
+
+Complete documentation for phomemo-tools, covering Linux and macOS support for Phomemo thermal label printers.
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Supported Printers](#supported-printers)
+3. [Quick Start](#quick-start)
+4. [Command-Line Tools](#command-line-tools)
+5. [CUPS Integration](#cups-integration)
+6. [macOS Support](#macos-support)
+7. [Protocol Reference](#protocol-reference)
+8. [Troubleshooting](#troubleshooting)
+
+---
+
+## Overview
+
+Phomemo-tools provides complete printing support for Phomemo thermal label printers on Linux and macOS. The package includes:
+
+- **Command-line tools** for direct printing via Bluetooth or USB
+- **CUPS backend** for printer discovery and connection management
+- **CUPS filters** for converting raster images to printer protocol
+- **PPD drivers** for printer capability definitions
+
+All protocol information has been reverse-engineered from Bluetooth packet captures.
+
+---
+
+## Supported Printers
+
+| Model | Resolution | Paper Width | Connection | Filter |
+|-------|-----------|-------------|------------|--------|
+| M02 | 203 dpi | 50mm (384 dots) | BT/USB | rastertopm02_t02 |
+| M02 Pro | 300 dpi | 50mm | BT/USB | rastertopm02_t02 |
+| T02 | 203 dpi | 50mm (384 dots) | BT/USB | rastertopm02_t02 |
+| M110 | 203 dpi | 20-50mm (344 dots max) | BT/USB | rastertopm110 |
+| M120 | 203 dpi | 20-50mm | BT/USB | rastertopm110 |
+| M220 | 203 dpi | 20-70mm | BT/USB | rastertopm110 |
+| M421 | 203 dpi | 40-70mm | BT/USB | rastertopm110 |
+| D30 | 203 dpi | 30-40mm | BT/USB | rastertopd30 |
+
+---
+
+## Quick Start
+
+### Linux
+
+```bash
+# Install dependencies
+sudo apt-get install cups python3-pil python3-pyusb
+
+# Clone and install
+git clone https://github.com/vivier/phomemo-tools.git
+cd phomemo-tools/cups
+make
+sudo make install
+
+# Add printer via CUPS web interface or CLI
+sudo lpadmin -p MyPhomemo -E -v phomemo://AABBCCDDEEFF \
+ -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
+```
+
+### macOS
+
+```bash
+# Install dependencies
+brew install libusb
+pip3 install Pillow pyusb pyobjc-framework-IOBluetooth
+
+# Clone and install
+git clone https://github.com/vivier/phomemo-tools.git
+cd phomemo-tools/cups
+make filters # Compile native C filter
+sudo make install
+
+# For Bluetooth CUPS printing, install helper daemon
+cd ../macos
+./install-bt-helper.sh
+```
+
+---
+
+## Command-Line Tools
+
+### phomemo-filter.py
+
+**Location:** `tools/phomemo-filter.py`
+
+Converts images to printer protocol and outputs to stdout.
+
+```bash
+# Print via Bluetooth (Linux)
+tools/phomemo-filter.py image.png > /dev/rfcomm0
+
+# Print via USB
+tools/phomemo-filter.py image.png > /dev/usb/lp0
+
+# Disable auto-rotation
+tools/phomemo-filter.py --no-rotate image.png > /dev/rfcomm0
+```
+
+**Options:**
+| Option | Description |
+|--------|-------------|
+| `--no-rotate` | Disable automatic rotation of landscape images |
+| `file` | Path to the image file (PNG, JPG, etc.) |
+
+### format-checker.py
+
+**Location:** `tools/format-checker.py`
+
+Validates and reconstructs images from printer protocol data. Useful for debugging.
+
+```bash
+tools/phomemo-filter.py image.png | tools/format-checker.py
+```
+
+---
+
+## CUPS Integration
+
+### Architecture
+
+```
+┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
+│ Application │────▶│ CUPS Daemon │────▶│ CUPS Backend │
+│ (lp, lpr, GUI) │ │ (cupsd) │ │ (phomemo) │
+└─────────────────┘ └────────┬─────────┘ └────────┬────────┘
+ │ │
+ ┌────────▼─────────┐ │
+ │ CUPS Filter │ │
+ │ (rastertopm*) │ │
+ └────────┬─────────┘ │
+ │ │
+ ┌────────▼─────────┐ ┌────────▼────────┐
+ │ Printer Data │────▶│ Printer Device │
+ │ (ESC/POS) │ │ (BT/USB) │
+ └──────────────────┘ └─────────────────┘
+```
+
+### CUPS Filters
+
+All filters convert CUPS Raster format to printer-specific ESC/POS protocol.
+
+| Filter | Printers | Notes |
+|--------|----------|-------|
+| rastertopm02_t02 | M02, M02 Pro, T02 | 255-line blocks |
+| rastertopm110 | M110, M120, M220, M421 | Speed/density control |
+| rastertopd30 | D30 | 90° rotation |
+
+### PPD Driver Files
+
+| Driver | Models | Resolution |
+|--------|--------|------------|
+| Phomemo-M02.ppd | M02, T02 | 203 dpi |
+| Phomemo-M02Pro.ppd | M02 Pro | 300 dpi |
+| Phomemo-M110.ppd | M110, M120 | 203 dpi |
+| Phomemo-M220.ppd | M220 | 203 dpi |
+| Phomemo-M421.ppd | M421 | 203 dpi |
+| Phomemo-D30.ppd | D30 | 203 dpi |
+
+### Adding a Printer
+
+**Via CLI (Bluetooth):**
+```bash
+sudo lpadmin -p M02 -E -v phomemo://DC0D309023C7 \
+ -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
+```
+
+**Via CLI (USB):**
+```bash
+sudo lpadmin -p M02 -E -v serial:/dev/usb/lp0 \
+ -P /usr/share/cups/model/Phomemo/Phomemo-M02.ppd.gz
+```
+
+**Printing:**
+```bash
+echo "Hello World" | lp -d M02 -o media=w50h60 -
+lp -d M02 -o media=w50h60 image.png
+```
+
+---
+
+## macOS Support
+
+Phomemo-tools provides **full macOS support** including Apple Silicon (M1/M2/M3/M4).
+
+### Feature Support
+
+| Feature | Status | Notes |
+|---------|--------|-------|
+| USB Printing | Full | Via PyUSB |
+| Bluetooth Printing | Full | Via IOBluetooth |
+| CUPS Filters | Full | Native C filter |
+| CUPS Bluetooth | Full | Via helper daemon |
+| Direct Printing | Full | Scripts in macos/ |
+
+### Installation
+
+#### Prerequisites
+
+```bash
+# Install Homebrew
+/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+# Install dependencies
+brew install libusb
+pip3 install Pillow pyusb pyobjc-framework-IOBluetooth
+```
+
+#### Install CUPS Components
+
+```bash
+cd cups
+make filters # Compile native C filter (required for macOS)
+sudo make install
+```
+
+#### Install Bluetooth Helper (for CUPS Bluetooth printing)
+
+```bash
+cd macos
+./install-bt-helper.sh
+```
+
+This installs:
+- Helper daemon at `~/Library/Application Support/Phomemo/`
+- LaunchAgent for auto-start
+- CUPS backend at `/usr/libexec/cups/backend/phomemo-bt`
+
+### Direct Printing (Without CUPS)
+
+#### USB Printing
+
+```bash
+cd macos
+python3 print-usb.py image.png
+```
+
+#### Bluetooth Printing
+
+```bash
+cd macos
+python3 print-bluetooth.py image.png
+```
+
+### CUPS Printing
+
+#### Adding a Bluetooth Printer
+
+1. Pair the printer in **System Settings > Bluetooth**
+2. Open **System Settings > Printers & Scanners**
+3. Click **+** to add a printer
+4. Select your Phomemo printer (phomemo-bt:// URI)
+5. Choose the appropriate PPD
+
+Or via CLI:
+```bash
+# Find printer address
+python3 -c "from IOBluetooth import IOBluetoothDevice; [print(f'{d.name()}: {d.addressString()}') for d in IOBluetoothDevice.pairedDevices() or []]"
+
+# Add printer
+sudo lpadmin -p M220_BT -E -v phomemo-bt://f9-29-79-d5-7b-fe \
+ -P /Library/Printers/PPDs/Contents/Resources/Phomemo/Phomemo-M220.ppd
+```
+
+### Architecture (macOS Bluetooth CUPS)
+
+Due to macOS TCC (Transparency, Consent, and Control) restrictions, CUPS daemons cannot directly access Bluetooth. The solution uses a helper daemon:
+
+```
+┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐
+│ CUPS │────▶│ Backend │────▶│ Unix Socket │
+│ Daemon │ │ phomemo-bt │ │ /tmp/phomemo-bt.sock│
+└─────────────┘ └─────────────┘ └──────────┬──────────┘
+ │
+ ┌──────────▼──────────┐
+ │ Helper Daemon │
+ │ (user LaunchAgent) │
+ └──────────┬──────────┘
+ │ IOBluetooth
+ ┌──────────▼──────────┐
+ │ Bluetooth Printer │
+ └─────────────────────┘
+```
+
+The helper daemon runs as a user process with Bluetooth permissions and communicates with the CUPS backend via Unix socket.
+
+### Troubleshooting (macOS)
+
+#### Filter Failed
+
+The Python CUPS filters don't work on macOS due to sandbox restrictions. Use the native C filter:
+
+```bash
+cd cups
+make filters
+sudo make install
+```
+
+#### Bluetooth Helper Not Running
+
+```bash
+# Check status
+ls -la /tmp/phomemo-bt.sock
+
+# View logs
+tail -f /tmp/phomemo-bt-helper.log
+
+# Restart helper
+launchctl unload ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+launchctl load ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+```
+
+#### Printer Not Found
+
+1. Ensure printer is paired in System Settings > Bluetooth
+2. Check PyObjC is installed: `python3 -c "from IOBluetooth import IOBluetoothDevice; print('OK')"`
+3. Grant Bluetooth access to Terminal in System Settings > Privacy & Security > Bluetooth
+
+---
+
+## Protocol Reference
+
+### M02/T02 Protocol (ESC/POS)
+
+**Header:**
+```
+1B 40 ESC @ Initialize printer
+1B 61 01 ESC a 1 Center justification
+1F 11 02 04 Phomemo-specific init
+```
+
+**Image Block (max 256 lines):**
+```
+1D 76 30 GS v 0 Print raster bit image
+00 Mode (0=normal)
+30 00 Bytes per line (48 = 384/8), little-endian
+FF 00 Lines (max 255), little-endian
+[image data] 48 bytes/line, MSB first
+```
+
+**Footer:**
+```
+1B 64 02 ESC d 2 Feed 2 lines
+1F 11 08/0E/07/09 Phomemo-specific
+```
+
+### M110/M120/M220/M421 Protocol
+
+**Header:**
+```
+1B 4E 0D 05 Print speed (01-05)
+1B 4E 04 0F Print density (01-0F)
+1F 11 0A Media type (0A=gaps, 0B=continuous, 26=marks)
+```
+
+**Image Block:**
+```
+1D 76 30 00 GS v 0
+2B 00 Bytes per line (43 = 344/8)
+F0 00 Lines
+[data]
+```
+
+**Footer:**
+```
+1F F0 05 00 End print
+1F F0 03 00 Finalize
+```
+
+---
+
+## Troubleshooting
+
+### Filter Failure Error
+
+Install Python dependencies:
+```bash
+# Linux
+sudo apt-get install python3-pil python3-pyusb
+
+# macOS - use native C filter instead
+cd cups && make filters && sudo make install
+```
+
+### Bluetooth Permission Denied (Linux)
+
+```bash
+# Fedora/SELinux
+sudo semanage permissive -a cupsd_t
+```
+
+### Printer Not Discovered
+
+1. Ensure printer is paired (Bluetooth) or connected (USB)
+2. Run backend manually: `/usr/lib/cups/backend/phomemo`
+3. Check CUPS logs: `tail -f /var/log/cups/error_log`
+
+### Image Quality Issues
+
+- Use high-contrast black & white images
+- Optimal width: 384 pixels (M02/T02), 344 pixels (M110)
+- Avoid gradients (thermal printers use dithering)
+
+---
+
+## License
+
+Phomemo-tools is licensed under the GNU General Public License v3.
+
+Image assets in the `images/` directory are provided under a separate license - see `images/LICENSE`.
diff --git a/macos/Makefile b/macos/Makefile
new file mode 100644
index 0000000..3c5c1a2
--- /dev/null
+++ b/macos/Makefile
@@ -0,0 +1,108 @@
+# Phomemo Tools - macOS Build and Installation
+#
+# This Makefile handles building and installing Phomemo printer drivers
+# for macOS with USB support only.
+#
+# Usage:
+# make check - Check dependencies
+# make install - Install drivers (requires sudo)
+# make uninstall - Remove drivers (requires sudo)
+# make test - Test USB device detection
+#
+
+SHELL := /bin/bash
+
+# Directories
+PROJECT_DIR := $(shell dirname $(CURDIR))
+CUPS_FILTER_DIR := /usr/local/libexec/cups/filter
+CUPS_BACKEND_DIR := /usr/local/libexec/cups/backend
+CUPS_PPD_DIR := /Library/Printers/PPDs/Contents/Resources/Phomemo
+SHARE_DIR := /usr/local/share/phomemo
+
+.PHONY: all check install uninstall test clean help
+
+all: check
+ @echo "Run 'sudo make install' to install the drivers"
+
+help:
+ @echo "Phomemo Tools - macOS USB Build"
+ @echo ""
+ @echo "Targets:"
+ @echo " check - Check dependencies"
+ @echo " install - Install drivers (requires sudo)"
+ @echo " uninstall - Remove drivers (requires sudo)"
+ @echo " test - Test USB device detection"
+ @echo ""
+
+check:
+ @echo "Checking dependencies..."
+ @echo ""
+ @echo "Python 3:"
+ @python3 --version || (echo "ERROR: Python 3 not found"; exit 1)
+ @echo ""
+ @echo "Python packages:"
+ @python3 -c "import PIL; print(' Pillow:', PIL.__version__)" 2>/dev/null || echo " Pillow: NOT INSTALLED (pip3 install Pillow)"
+ @python3 -c "import usb; print(' PyUSB: OK')" 2>/dev/null || echo " PyUSB: NOT INSTALLED (pip3 install pyusb)"
+ @echo ""
+ @echo "libusb (via Homebrew):"
+ @brew list libusb >/dev/null 2>&1 && echo " libusb: OK" || echo " libusb: NOT INSTALLED (brew install libusb)"
+ @echo ""
+
+install:
+ @if [ "$$(id -u)" != "0" ]; then \
+ echo "Error: This target requires root privileges. Use 'sudo make install'"; \
+ exit 1; \
+ fi
+ @echo "Installing Phomemo USB drivers for macOS..."
+ @mkdir -p $(CUPS_FILTER_DIR)
+ @mkdir -p $(CUPS_BACKEND_DIR)
+ @mkdir -p $(CUPS_PPD_DIR)
+ @mkdir -p $(SHARE_DIR)
+ @echo " Installing filters..."
+ @install -m 755 $(PROJECT_DIR)/cups/filter/rastertopm02_t02.py $(CUPS_FILTER_DIR)/rastertopm02_t02
+ @install -m 755 $(PROJECT_DIR)/cups/filter/rastertopm110.py $(CUPS_FILTER_DIR)/rastertopm110
+ @install -m 755 $(PROJECT_DIR)/cups/filter/rastertopd30.py $(CUPS_FILTER_DIR)/rastertopd30
+ @echo " Installing USB backend..."
+ @install -m 755 backend/phomemo-usb.py $(CUPS_BACKEND_DIR)/phomemo
+ @echo " Installing tools..."
+ @install -m 755 $(PROJECT_DIR)/tools/phomemo-filter.py $(SHARE_DIR)/phomemo-filter.py
+ @install -m 644 $(PROJECT_DIR)/README.md $(SHARE_DIR)/ 2>/dev/null || true
+ @install -m 644 $(PROJECT_DIR)/LICENSE $(SHARE_DIR)/ 2>/dev/null || true
+ @if [ -d "$(PROJECT_DIR)/cups/ppd" ]; then \
+ echo " Installing PPD files..."; \
+ for ppd in $(PROJECT_DIR)/cups/ppd/*.ppd.gz; do \
+ [ -f "$$ppd" ] && install -m 644 "$$ppd" $(CUPS_PPD_DIR)/; \
+ done; \
+ else \
+ echo " Warning: PPD files not found. Build them first with 'make -C ../cups'"; \
+ fi
+ @echo " Restarting CUPS..."
+ @launchctl stop org.cups.cupsd 2>/dev/null || true
+ @launchctl start org.cups.cupsd 2>/dev/null || true
+ @echo ""
+ @echo "Installation complete!"
+ @echo "Connect your Phomemo printer via USB and add it in System Preferences."
+
+uninstall:
+ @if [ "$$(id -u)" != "0" ]; then \
+ echo "Error: This target requires root privileges. Use 'sudo make uninstall'"; \
+ exit 1; \
+ fi
+ @echo "Removing Phomemo drivers..."
+ @rm -f $(CUPS_FILTER_DIR)/rastertopm02_t02
+ @rm -f $(CUPS_FILTER_DIR)/rastertopm110
+ @rm -f $(CUPS_FILTER_DIR)/rastertopd30
+ @rm -f $(CUPS_BACKEND_DIR)/phomemo
+ @rm -rf $(CUPS_PPD_DIR)
+ @rm -rf $(SHARE_DIR)
+ @launchctl stop org.cups.cupsd 2>/dev/null || true
+ @launchctl start org.cups.cupsd 2>/dev/null || true
+ @echo "Uninstall complete."
+
+test:
+ @echo "Testing USB device detection..."
+ @echo ""
+ @python3 backend/phomemo-usb.py || echo "No devices found or error occurred"
+
+clean:
+ @echo "Nothing to clean for macOS build"
diff --git a/macos/README.md b/macos/README.md
new file mode 100644
index 0000000..62ca98b
--- /dev/null
+++ b/macos/README.md
@@ -0,0 +1,242 @@
+# Phomemo Tools - macOS Support
+
+Full macOS support for Phomemo thermal printers, including Apple Silicon (M1/M2/M3/M4).
+
+## Features
+
+| Feature | Status | Method |
+|---------|--------|--------|
+| USB Printing | Full | PyUSB |
+| Bluetooth Printing | Full | IOBluetooth/PyObjC |
+| CUPS Integration | Full | Native C filter + helper daemon |
+| Direct Printing | Full | Python scripts |
+
+## Supported Printers
+
+- Phomemo M02, M02 Pro, T02
+- Phomemo M110, M120, M220, M421
+- Phomemo D30
+
+## Requirements
+
+- macOS 10.15 (Catalina) or later
+- Python 3.8 or later
+- Xcode Command Line Tools
+
+## Quick Start
+
+### 1. Install Dependencies
+
+```bash
+# Install Homebrew (if not installed)
+/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+# Install libusb
+brew install libusb
+
+# Install Python packages
+pip3 install Pillow pyusb pyobjc-framework-IOBluetooth
+```
+
+### 2. Direct Printing (Simplest)
+
+#### Bluetooth
+
+```bash
+# Pair printer in System Settings > Bluetooth first
+python3 print-bluetooth.py image.png
+```
+
+#### USB
+
+```bash
+python3 print-usb.py image.png
+```
+
+### 3. CUPS Printing (Full Integration)
+
+```bash
+# Install CUPS components
+cd ../cups
+make filters
+sudo make install
+
+# Install Bluetooth helper (for BT printing via CUPS)
+cd ../macos
+./install-bt-helper.sh
+
+# Add printer in System Settings > Printers & Scanners
+```
+
+## Direct Printing Scripts
+
+### print-bluetooth.py
+
+Prints directly to a Bluetooth-paired Phomemo printer.
+
+```bash
+python3 print-bluetooth.py
+```
+
+Features:
+- Auto-detects paired Phomemo printers
+- Converts images to printer format
+- Supports all Phomemo models
+
+### print-usb.py
+
+Prints directly via USB connection.
+
+```bash
+python3 print-usb.py
+```
+
+Features:
+- Auto-detects USB Phomemo printers
+- Supports multiple vendor IDs (0x0493, 0x0483)
+- Works with all USB-capable models
+
+## CUPS Integration
+
+### How It Works
+
+macOS security (TCC) prevents CUPS from accessing Bluetooth directly. This is solved with a helper daemon architecture:
+
+```
+CUPS → phomemo-bt backend → Unix socket → Helper daemon → Bluetooth → Printer
+```
+
+The helper daemon runs as a user LaunchAgent with Bluetooth permissions.
+
+### Installation
+
+```bash
+./install-bt-helper.sh
+```
+
+This installs:
+- `phomemo-bt-helper.py` → `~/Library/Application Support/Phomemo/`
+- `com.phomemo.bt-helper.plist` → `~/Library/LaunchAgents/`
+- `phomemo-bt` → `/usr/libexec/cups/backend/`
+
+### Adding a Bluetooth Printer
+
+1. Pair printer in **System Settings > Bluetooth**
+2. Open **System Settings > Printers & Scanners**
+3. Click **+** to add printer
+4. Your Phomemo printer should appear with `phomemo-bt://` URI
+5. Select appropriate PPD driver
+
+### Manual Printer Setup
+
+```bash
+# List paired Bluetooth devices
+python3 -c "
+from IOBluetooth import IOBluetoothDevice
+for d in IOBluetoothDevice.pairedDevices() or []:
+ print(f'{d.name()}: {d.addressString()}')
+"
+
+# Add printer manually
+sudo lpadmin -p M220_BT -E \
+ -v phomemo-bt://f9-29-79-d5-7b-fe \
+ -P /Library/Printers/PPDs/Contents/Resources/Phomemo/Phomemo-M220.ppd
+
+# Print test
+echo "Hello" | lp -d M220_BT
+```
+
+## Troubleshooting
+
+### Bluetooth Helper Not Running
+
+```bash
+# Check socket exists
+ls -la /tmp/phomemo-bt.sock
+
+# View logs
+tail -f /tmp/phomemo-bt-helper.log
+
+# Restart helper
+launchctl unload ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+launchctl load ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+```
+
+### "No module named 'objc'"
+
+Install PyObjC:
+```bash
+pip3 install pyobjc-framework-IOBluetooth
+```
+
+### "No module named 'usb'"
+
+Install PyUSB:
+```bash
+pip3 install pyusb
+brew install libusb
+```
+
+### Bluetooth Printer Not Found
+
+1. Check printer is paired in System Settings > Bluetooth
+2. Grant Bluetooth access to Terminal: System Settings > Privacy & Security > Bluetooth
+3. Test discovery:
+ ```bash
+ python3 -c "from IOBluetooth import IOBluetoothDevice; print([d.name() for d in IOBluetoothDevice.pairedDevices() or []])"
+ ```
+
+### CUPS Filter Failed
+
+macOS sandbox blocks Python filters. Use the native C filter:
+```bash
+cd ../cups
+make filters
+sudo make install
+```
+
+### USB Device Not Found
+
+1. Check printer is connected and powered on
+2. Try different USB port
+3. Check USB permissions in System Settings > Privacy & Security
+4. List USB devices:
+ ```bash
+ system_profiler SPUSBDataType | grep -A5 -i phomemo
+ ```
+
+### CUPS Printer Disabled After Error
+
+```bash
+cupsenable
+```
+
+## Uninstallation
+
+```bash
+# Remove helper daemon
+launchctl unload ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+rm ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+rm -rf ~/Library/Application\ Support/Phomemo
+
+# Remove CUPS backend (requires sudo)
+sudo rm /usr/libexec/cups/backend/phomemo-bt
+
+# Remove CUPS filter and PPDs
+sudo rm /usr/libexec/cups/filter/rastertopm110
+sudo rm -rf /Library/Printers/PPDs/Contents/Resources/Phomemo
+```
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `print-bluetooth.py` | Direct Bluetooth printing script |
+| `print-usb.py` | Direct USB printing script |
+| `phomemo-bt-helper.py` | CUPS Bluetooth helper daemon |
+| `com.phomemo.bt-helper.plist` | LaunchAgent for helper daemon |
+| `install-bt-helper.sh` | Installation script |
+
+## License
+
+GNU General Public License v3
diff --git a/macos/backend/phomemo-usb.py b/macos/backend/phomemo-usb.py
new file mode 100644
index 0000000..eab1635
--- /dev/null
+++ b/macos/backend/phomemo-usb.py
@@ -0,0 +1,175 @@
+#! /usr/bin/python3
+
+"""
+Phomemo CUPS Backend for macOS - USB Only
+
+This backend handles USB printer discovery and printing for Phomemo
+thermal printers on macOS. Bluetooth is not supported on macOS due
+to different Bluetooth stack requirements.
+
+Usage:
+ As CUPS backend (discovery): ./phomemo-usb
+ As CUPS backend (print job): DEVICE_URI=usb://... ./phomemo-usb job user title copies options [file]
+"""
+
+import sys
+import os
+from urllib.parse import quote, unquote, parse_qs
+
+# Device identification string for CUPS
+DEVICE_ID = 'CLS:PRINTER;CMD:EPSON;DES:Thermal Printer;MFG:Phomemo;MDL:'
+
+# Vendor ID for Phomemo printers (MAG Technology)
+PHOMEMO_VENDOR_ID = 0x0493
+
+# Known product IDs
+PRODUCT_IDS = {
+ 0xb002: 'M02',
+ 0x8760: 'M110',
+ 0x8761: 'M110', # Alternative ID
+ 0x8762: 'M120',
+ 0x8763: 'M220',
+ 0x8764: 'M421',
+}
+
+
+class FindPrinterClass:
+ """USB device matcher for printer class devices."""
+
+ def __init__(self, device_class=7):
+ self._class = device_class
+
+ def __call__(self, device):
+ if device.bDeviceClass == self._class:
+ return True
+
+ for cfg in device:
+ import usb.util
+ intf = usb.util.find_descriptor(cfg, bInterfaceClass=self._class)
+ if intf is not None:
+ return True
+
+ return False
+
+
+def scan_usb():
+ """Scan for Phomemo USB printers and output CUPS device lines."""
+ try:
+ import usb.core
+ import usb.util
+ except ImportError:
+ print("WARNING: PyUSB not found. Install with: pip3 install pyusb", file=sys.stderr)
+ print("WARNING: On macOS, also install libusb: brew install libusb", file=sys.stderr)
+ return
+
+ try:
+ printers = usb.core.find(
+ find_all=True,
+ custom_match=FindPrinterClass(7),
+ idVendor=PHOMEMO_VENDOR_ID
+ )
+ except usb.core.USBError as e:
+ print(f"WARNING: USB access error: {e}", file=sys.stderr)
+ print("WARNING: On macOS, you may need to allow USB access in System Preferences", file=sys.stderr)
+ return
+
+ for printer in printers:
+ try:
+ interface_num = None
+ for cfg in printer:
+ import usb.util
+ intf = usb.util.find_descriptor(cfg, bInterfaceClass=7)
+ if intf is not None:
+ interface_num = intf.bInterfaceNumber
+ break
+
+ if interface_num is None:
+ continue
+
+ # Get model name from product ID
+ model = PRODUCT_IDS.get(printer.idProduct, f'Unknown(0x{printer.idProduct:04x})')
+
+ # Get serial number
+ try:
+ usb.util.get_langids(printer)
+ serial_number = usb.util.get_string(printer, printer.iSerialNumber)
+ except Exception:
+ serial_number = 'UNKNOWN'
+
+ # Get manufacturer and product strings
+ try:
+ manufacturer = usb.util.get_string(printer, printer.iManufacturer) or 'Phomemo'
+ product = usb.util.get_string(printer, printer.iProduct) or model
+ except Exception:
+ manufacturer = 'Phomemo'
+ product = model
+
+ # Build device URI
+ device_uri = 'usb://{}/{}?serial={}&interface={}'.format(
+ quote(manufacturer),
+ quote(product),
+ serial_number,
+ interface_num
+ )
+
+ device_make_and_model = f'Phomemo {model}'
+
+ # Output CUPS device line format:
+ # device-class device-uri "device-make-and-model" "device-info" "device-id"
+ print(f'direct {device_uri} "{device_make_and_model}" '
+ f'"{device_make_and_model} USB {serial_number}" '
+ f'"{DEVICE_ID}{model} (USB);"')
+
+ except Exception as e:
+ print(f"WARNING: Error processing USB device: {e}", file=sys.stderr)
+ continue
+
+
+def print_job():
+ """Handle a print job from CUPS."""
+ device_uri = os.environ.get('DEVICE_URI', '')
+
+ if not device_uri:
+ print("ERROR: No DEVICE_URI environment variable", file=sys.stderr)
+ return 1
+
+ # For USB URIs, CUPS handles the actual data transmission through the usb backend
+ # This backend just needs to forward the data
+ print(f'DEBUG: {sys.argv[0]} handling device {device_uri}', file=sys.stderr)
+
+ # For USB printers, the data is typically handled by CUPS' built-in usb backend
+ # Our backend is mainly for device discovery
+ # If we get here with a print job, we pass through to stdout
+
+ print('STATE: +connecting-to-device', file=sys.stderr)
+ print('STATE: +sending-data', file=sys.stderr)
+
+ try:
+ with os.fdopen(sys.stdin.fileno(), 'rb', closefd=False) as stdin:
+ with os.fdopen(sys.stdout.fileno(), 'wb', closefd=False) as stdout:
+ while True:
+ data = stdin.read(8192)
+ if not data:
+ break
+ stdout.write(data)
+ print(f'DEBUG: sent {len(data)} bytes', file=sys.stderr)
+ except Exception as e:
+ print(f"ERROR: Failed to send print data: {e}", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+def main():
+ # No arguments = device discovery mode
+ if len(sys.argv) == 1:
+ scan_usb()
+ return 0
+
+ # With arguments = print job mode
+ # CUPS calls backend with: job-id user title copies options [file]
+ return print_job()
+
+
+if __name__ == '__main__':
+ sys.exit(main() or 0)
diff --git a/macos/com.phomemo.bt-helper.plist b/macos/com.phomemo.bt-helper.plist
new file mode 100644
index 0000000..4c68e3f
--- /dev/null
+++ b/macos/com.phomemo.bt-helper.plist
@@ -0,0 +1,33 @@
+
+
+
+
+ Label
+ com.phomemo.bt-helper
+
+ ProgramArguments
+
+ /bin/bash
+ -c
+ exec python3 "$HOME/Library/Application Support/Phomemo/phomemo-bt-helper.py"
+
+
+ EnvironmentVariables
+
+ PATH
+ /opt/anaconda3/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+
+ StandardErrorPath
+ /tmp/phomemo-bt-helper.log
+
+ StandardOutPath
+ /tmp/phomemo-bt-helper.log
+
+
diff --git a/macos/install-bt-helper.sh b/macos/install-bt-helper.sh
new file mode 100755
index 0000000..0baa639
--- /dev/null
+++ b/macos/install-bt-helper.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+#
+# Install Phomemo Bluetooth helper for CUPS printing on macOS
+#
+# This installs:
+# - The helper daemon (runs as user with Bluetooth permissions)
+# - The CUPS backend (connects to helper via socket)
+# - LaunchAgent to auto-start helper on login
+#
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SUPPORT_DIR="$HOME/Library/Application Support/Phomemo"
+LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
+
+echo "Installing Phomemo Bluetooth helper..."
+
+# Check for PyObjC
+if ! python3 -c "import objc; from IOBluetooth import IOBluetoothDevice" 2>/dev/null; then
+ echo "PyObjC is required. Installing..."
+ pip3 install pyobjc-framework-IOBluetooth || {
+ echo "Error: Could not install PyObjC. Please install manually:"
+ echo " pip3 install pyobjc-framework-IOBluetooth"
+ exit 1
+ }
+fi
+
+# Create directories
+mkdir -p "$SUPPORT_DIR"
+mkdir -p "$LAUNCH_AGENTS"
+
+# Install helper daemon
+echo "Installing helper daemon..."
+cp "$SCRIPT_DIR/phomemo-bt-helper.py" "$SUPPORT_DIR/"
+chmod 755 "$SUPPORT_DIR/phomemo-bt-helper.py"
+
+# Find Python path with PyObjC
+PYTHON_PATH=$(python3 -c "import sys; print(sys.executable)")
+echo "Using Python: $PYTHON_PATH"
+
+# Install LaunchAgent (with expanded paths)
+echo "Installing LaunchAgent..."
+cat > "$LAUNCH_AGENTS/com.phomemo.bt-helper.plist" << EOF
+
+
+
+
+ Label
+ com.phomemo.bt-helper
+ ProgramArguments
+
+ $PYTHON_PATH
+ $SUPPORT_DIR/phomemo-bt-helper.py
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardErrorPath
+ /tmp/phomemo-bt-helper.log
+ StandardOutPath
+ /tmp/phomemo-bt-helper.log
+
+
+EOF
+
+# Install CUPS backend
+echo "Installing CUPS backend..."
+sudo cp "$SCRIPT_DIR/../cups/backend/phomemo-bt-socket" /usr/libexec/cups/backend/phomemo-bt
+sudo chmod 755 /usr/libexec/cups/backend/phomemo-bt
+sudo chown root:wheel /usr/libexec/cups/backend/phomemo-bt
+
+# Stop existing helper if running
+launchctl unload "$LAUNCH_AGENTS/com.phomemo.bt-helper.plist" 2>/dev/null || true
+
+# Start helper
+echo "Starting helper daemon..."
+launchctl load "$LAUNCH_AGENTS/com.phomemo.bt-helper.plist"
+
+# Wait for socket
+sleep 2
+if [ -S /tmp/phomemo-bt.sock ]; then
+ echo "Helper is running!"
+else
+ echo "Warning: Helper socket not found. Check /tmp/phomemo-bt-helper.log"
+fi
+
+echo ""
+echo "Installation complete!"
+echo ""
+echo "To add a Bluetooth printer:"
+echo " 1. Pair the printer in System Settings > Bluetooth"
+echo " 2. Open System Settings > Printers & Scanners"
+echo " 3. Click '+' to add a printer"
+echo " 4. Your Phomemo printer should appear with 'phomemo-bt://' URI"
+echo ""
+echo "If the printer doesn't appear, you may need to grant Bluetooth access:"
+echo " System Settings > Privacy & Security > Bluetooth"
+echo " Add 'Terminal' or the app running this helper"
diff --git a/macos/install.sh b/macos/install.sh
new file mode 100755
index 0000000..6ce5305
--- /dev/null
+++ b/macos/install.sh
@@ -0,0 +1,153 @@
+#!/bin/bash
+#
+# Phomemo Tools - macOS USB Installation Script
+#
+# This script installs Phomemo printer drivers for macOS with USB support.
+# Bluetooth is not supported on macOS due to different Bluetooth stack requirements.
+#
+# Usage: sudo ./install.sh
+#
+# Requirements:
+# - macOS 10.15 (Catalina) or later
+# - Python 3.8+
+# - Homebrew (for libusb)
+# - PyUSB and Pillow Python packages
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# CUPS directories on macOS
+CUPS_FILTER_DIR="/usr/local/libexec/cups/filter"
+CUPS_BACKEND_DIR="/usr/local/libexec/cups/backend"
+CUPS_PPD_DIR="/Library/Printers/PPDs/Contents/Resources"
+SHARE_DIR="/usr/local/share/phomemo"
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+echo "======================================"
+echo " Phomemo Tools - macOS USB Installer"
+echo "======================================"
+echo ""
+
+# Check if running as root
+if [ "$EUID" -ne 0 ]; then
+ echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
+ exit 1
+fi
+
+# Check for Python 3
+if ! command -v python3 &> /dev/null; then
+ echo -e "${RED}Error: Python 3 is required but not installed${NC}"
+ echo "Install Python 3 from https://python.org or via Homebrew: brew install python3"
+ exit 1
+fi
+
+PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+echo -e "${GREEN}Found Python ${PYTHON_VERSION}${NC}"
+
+# Check for required Python packages
+echo "Checking Python dependencies..."
+
+check_python_package() {
+ if python3 -c "import $1" 2>/dev/null; then
+ echo -e " ${GREEN}$1 - OK${NC}"
+ return 0
+ else
+ echo -e " ${YELLOW}$1 - Missing${NC}"
+ return 1
+ fi
+}
+
+MISSING_PACKAGES=""
+if ! check_python_package "PIL"; then
+ MISSING_PACKAGES="$MISSING_PACKAGES Pillow"
+fi
+if ! check_python_package "usb"; then
+ MISSING_PACKAGES="$MISSING_PACKAGES pyusb"
+fi
+
+if [ -n "$MISSING_PACKAGES" ]; then
+ echo ""
+ echo -e "${YELLOW}Missing Python packages:$MISSING_PACKAGES${NC}"
+ echo "Install them with: pip3 install$MISSING_PACKAGES"
+ echo ""
+ read -p "Do you want to continue anyway? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ exit 1
+ fi
+fi
+
+# Check for libusb (required by PyUSB on macOS)
+if ! brew list libusb &>/dev/null 2>&1; then
+ echo -e "${YELLOW}Warning: libusb not found via Homebrew${NC}"
+ echo "PyUSB requires libusb. Install with: brew install libusb"
+fi
+
+echo ""
+echo "Installing Phomemo drivers..."
+
+# Create directories
+echo " Creating directories..."
+mkdir -p "$CUPS_FILTER_DIR"
+mkdir -p "$CUPS_BACKEND_DIR"
+mkdir -p "$CUPS_PPD_DIR/Phomemo"
+mkdir -p "$SHARE_DIR"
+
+# Install filters
+echo " Installing CUPS filters..."
+install -m 755 "$PROJECT_DIR/cups/filter/rastertopm02_t02.py" "$CUPS_FILTER_DIR/rastertopm02_t02"
+install -m 755 "$PROJECT_DIR/cups/filter/rastertopm110.py" "$CUPS_FILTER_DIR/rastertopm110"
+install -m 755 "$PROJECT_DIR/cups/filter/rastertopd30.py" "$CUPS_FILTER_DIR/rastertopd30"
+
+# Install USB backend
+echo " Installing USB backend..."
+install -m 755 "$SCRIPT_DIR/backend/phomemo-usb.py" "$CUPS_BACKEND_DIR/phomemo"
+
+# Install tools
+echo " Installing tools..."
+install -m 755 "$PROJECT_DIR/tools/phomemo-filter.py" "$SHARE_DIR/phomemo-filter.py"
+install -m 644 "$PROJECT_DIR/README.md" "$SHARE_DIR/"
+install -m 644 "$PROJECT_DIR/LICENSE" "$SHARE_DIR/"
+
+# Check if PPDs exist (they need to be built first)
+if [ -d "$PROJECT_DIR/cups/ppd" ]; then
+ echo " Installing PPD files..."
+ for ppd in "$PROJECT_DIR/cups/ppd"/*.ppd.gz; do
+ if [ -f "$ppd" ]; then
+ install -m 644 "$ppd" "$CUPS_PPD_DIR/Phomemo/"
+ fi
+ done
+else
+ echo -e " ${YELLOW}Warning: PPD files not found. Run 'make' in the cups directory first.${NC}"
+fi
+
+# Restart CUPS
+echo ""
+echo "Restarting CUPS..."
+launchctl stop org.cups.cupsd 2>/dev/null || true
+launchctl start org.cups.cupsd 2>/dev/null || true
+
+echo ""
+echo -e "${GREEN}Installation complete!${NC}"
+echo ""
+echo "Next steps:"
+echo " 1. Connect your Phomemo printer via USB"
+echo " 2. Open System Preferences > Printers & Scanners"
+echo " 3. Click '+' to add a printer"
+echo " 4. Select your Phomemo printer from the list"
+echo " 5. Choose the appropriate PPD driver"
+echo ""
+echo "For direct printing (without CUPS), use:"
+echo " python3 $SHARE_DIR/phomemo-filter.py image.png | cat > /dev/cu.usbmodem*"
+echo ""
+echo "Note: On Apple Silicon Macs, you may need to allow the USB device"
+echo " in System Preferences > Security & Privacy."
diff --git a/macos/phomemo-bt-helper.py b/macos/phomemo-bt-helper.py
new file mode 100644
index 0000000..fa63771
--- /dev/null
+++ b/macos/phomemo-bt-helper.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+Phomemo Bluetooth Helper Daemon
+
+Runs as a user LaunchAgent with Bluetooth permissions.
+CUPS backend connects via Unix socket to send print data.
+
+Install:
+ cp phomemo-bt-helper.py ~/Library/Application\\ Support/Phomemo/
+ cp com.phomemo.bt-helper.plist ~/Library/LaunchAgents/
+ launchctl load ~/Library/LaunchAgents/com.phomemo.bt-helper.plist
+"""
+
+import os
+import sys
+import socket
+import struct
+import time
+import json
+
+# PyObjC imports
+try:
+ import objc
+ from Foundation import NSObject, NSRunLoop, NSDate, NSDefaultRunLoopMode
+ from IOBluetooth import IOBluetoothDevice, IOBluetoothRFCOMMChannel
+ BLUETOOTH_AVAILABLE = True
+except ImportError as e:
+ BLUETOOTH_AVAILABLE = False
+ print(f"Bluetooth not available: {e}", file=sys.stderr)
+
+SOCKET_PATH = "/tmp/phomemo-bt.sock"
+
+
+class RFCOMMDelegate(NSObject):
+ """Delegate for RFCOMM channel callbacks."""
+
+ def init(self):
+ self = objc.super(RFCOMMDelegate, self).init()
+ if self is None:
+ return None
+ self.is_open = False
+ self.error = None
+ return self
+
+ def rfcommChannelOpenComplete_status_(self, channel, status):
+ if status == 0:
+ self.is_open = True
+ else:
+ self.error = f"Open failed: {status}"
+
+ def rfcommChannelClosed_(self, channel):
+ self.is_open = False
+
+
+def resolve_device(address_or_name):
+ """Resolve device by address or name."""
+ # Check if it looks like a MAC address (XX-XX-XX-XX-XX-XX or XX:XX:XX:XX:XX:XX)
+ import re
+ if re.match(r'^([0-9a-fA-F]{2}[-:]){5}[0-9a-fA-F]{2}$', address_or_name):
+ # It's an address
+ device = IOBluetoothDevice.deviceWithAddressString_(address_or_name)
+ if device:
+ return device
+
+ # Try to find by name in paired devices
+ for device in IOBluetoothDevice.pairedDevices() or []:
+ name = device.name()
+ if name and name == address_or_name:
+ return device
+
+ # Not found
+ return None
+
+
+def connect_bluetooth(address_or_name, channel_id=1, timeout=10.0):
+ """Connect to Bluetooth device and return RFCOMM channel."""
+ device = resolve_device(address_or_name)
+ if not device:
+ raise ValueError(f"Device not found: {address_or_name}")
+
+ delegate = RFCOMMDelegate.alloc().init()
+
+ result = device.openRFCOMMChannelSync_withChannelID_delegate_(
+ None, channel_id, delegate
+ )
+
+ if isinstance(result, tuple):
+ status, channel = result
+ else:
+ status = result
+ channel = None
+
+ if status != 0:
+ raise ConnectionError(f"RFCOMM open failed: {status}")
+
+ # Wait for connection
+ deadline = time.time() + timeout
+ while not delegate.is_open and not delegate.error:
+ NSRunLoop.currentRunLoop().runMode_beforeDate_(
+ NSDefaultRunLoopMode,
+ NSDate.dateWithTimeIntervalSinceNow_(0.1)
+ )
+ if time.time() > deadline:
+ raise TimeoutError("Connection timeout")
+
+ if delegate.error:
+ raise ConnectionError(delegate.error)
+
+ time.sleep(0.5) # Stabilize connection
+ return channel
+
+
+def send_data(channel, data):
+ """Send data over RFCOMM channel."""
+ chunk_size = 512
+ for i in range(0, len(data), chunk_size):
+ chunk = data[i:i+chunk_size]
+ result = channel.writeSync_length_(chunk, len(chunk))
+ if result != 0:
+ raise IOError(f"Write failed: {result}")
+ time.sleep(0.01)
+ return len(data)
+
+
+def handle_client(conn):
+ """Handle a client connection from CUPS backend."""
+ try:
+ # Read header: address length (4 bytes) + address + data
+ header = conn.recv(4)
+ if len(header) < 4:
+ conn.send(b"ERR:Invalid header\n")
+ return
+
+ addr_len = struct.unpack("!I", header)[0]
+ address = conn.recv(addr_len).decode('utf-8')
+
+ print(f"Connecting to {address}...", file=sys.stderr)
+
+ # Connect Bluetooth
+ channel = connect_bluetooth(address)
+
+ conn.send(b"OK:Connected\n")
+
+ # Receive and forward data
+ total = 0
+ while True:
+ data = conn.recv(4096)
+ if not data:
+ break
+ send_data(channel, data)
+ total += len(data)
+
+ channel.closeChannel()
+ conn.send(f"OK:Sent {total} bytes\n".encode())
+ print(f"Sent {total} bytes to {address}", file=sys.stderr)
+
+ except Exception as e:
+ error_msg = f"ERR:{e}\n"
+ try:
+ conn.send(error_msg.encode())
+ except:
+ pass
+ print(f"Error: {e}", file=sys.stderr)
+
+
+def main():
+ if not BLUETOOTH_AVAILABLE:
+ print("Bluetooth not available", file=sys.stderr)
+ return 1
+
+ # Remove old socket
+ try:
+ os.unlink(SOCKET_PATH)
+ except FileNotFoundError:
+ pass
+
+ # Create Unix socket
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ server.bind(SOCKET_PATH)
+ os.chmod(SOCKET_PATH, 0o666) # Allow all users to connect
+ server.listen(5)
+
+ print(f"Phomemo Bluetooth helper listening on {SOCKET_PATH}", file=sys.stderr)
+
+ while True:
+ conn, _ = server.accept()
+ try:
+ handle_client(conn)
+ finally:
+ conn.close()
+
+
+if __name__ == '__main__':
+ sys.exit(main() or 0)
diff --git a/macos/print-bluetooth.py b/macos/print-bluetooth.py
new file mode 100644
index 0000000..d67ad0c
--- /dev/null
+++ b/macos/print-bluetooth.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+"""
+Direct Bluetooth printing for Phomemo M110/M220 printers on macOS.
+
+Requirements:
+ pip install pyobjc-framework-IOBluetooth pillow
+
+Usage:
+ python3 print-bluetooth.py
+ python3 print-bluetooth.py --list # List paired Phomemo printers
+"""
+
+import sys
+import os
+import argparse
+import time
+from PIL import Image, ImageOps
+
+# PyObjC imports for Bluetooth
+try:
+ import objc
+ from Foundation import NSObject, NSRunLoop, NSDate, NSDefaultRunLoopMode
+ from IOBluetooth import IOBluetoothDevice, IOBluetoothRFCOMMChannel
+ BLUETOOTH_AVAILABLE = True
+except ImportError as e:
+ BLUETOOTH_AVAILABLE = False
+ BLUETOOTH_ERROR = str(e)
+
+# Printer commands
+ESC = b'\x1b'
+GS = b'\x1d'
+
+# Known Phomemo printer name patterns
+PHOMEMO_PATTERNS = ['M02', 'M110', 'M120', 'M220', 'M421', 'T02', 'D30']
+
+# Some printers use serial number as name (e.g., Q198G43S2490044)
+import re
+SERIAL_PATTERN = re.compile(r'^[A-Z]\d{3}[A-Z]\d{2}[A-Z]\d+$')
+
+
+class RFCOMMDelegate(NSObject):
+ """Delegate for RFCOMM channel callbacks."""
+
+ def init(self):
+ self = objc.super(RFCOMMDelegate, self).init()
+ if self is None:
+ return None
+ self.is_open = False
+ self.is_closed = False
+ self.error = None
+ return self
+
+ def rfcommChannelOpenComplete_status_(self, channel, status):
+ if status == 0:
+ self.is_open = True
+ else:
+ self.error = f"Open failed: {status}"
+
+ def rfcommChannelClosed_(self, channel):
+ self.is_closed = True
+ self.is_open = False
+
+
+def find_phomemo_printers():
+ """Find paired Phomemo printers."""
+ if not BLUETOOTH_AVAILABLE:
+ print(f"Bluetooth not available: {BLUETOOTH_ERROR}", file=sys.stderr)
+ return []
+
+ printers = []
+ paired = IOBluetoothDevice.pairedDevices()
+
+ if not paired:
+ return printers
+
+ for device in paired:
+ name = device.name()
+ if not name:
+ continue
+
+ name = str(name)
+ # Check if this looks like a Phomemo printer
+ model = None
+ for pattern in PHOMEMO_PATTERNS:
+ if pattern.lower() in name.lower():
+ model = pattern
+ break
+
+ # Also check for serial number pattern (some printers use serial as name)
+ if not model and SERIAL_PATTERN.match(name):
+ model = 'Phomemo'
+
+ if model:
+ addr = str(device.addressString())
+ printers.append({
+ 'device': device,
+ 'name': name,
+ 'address': addr,
+ 'model': model,
+ })
+
+ return printers
+
+
+def list_printers():
+ """List paired Phomemo printers."""
+ if not BLUETOOTH_AVAILABLE:
+ print(f"Bluetooth not available: {BLUETOOTH_ERROR}")
+ print("\nInstall with: pip install pyobjc-framework-IOBluetooth")
+ return
+
+ printers = find_phomemo_printers()
+ if not printers:
+ print("No Phomemo printers found in paired devices.")
+ print("\nMake sure the printer is:")
+ print(" - Turned on and in Bluetooth mode")
+ print(" - Paired via System Settings > Bluetooth")
+ print("\nPaired devices on this Mac:")
+ paired = IOBluetoothDevice.pairedDevices()
+ if paired:
+ for d in paired:
+ name = d.name()
+ if name:
+ print(f" - {name}")
+ return
+
+ print(f"Found {len(printers)} Phomemo printer(s):\n")
+ for i, p in enumerate(printers):
+ print(f" [{i}] {p['model']} - {p['name']} ({p['address']})")
+
+
+def connect_rfcomm(device, channel_id=1, timeout=10.0):
+ """Open RFCOMM connection to device."""
+ delegate = RFCOMMDelegate.alloc().init()
+
+ result = device.openRFCOMMChannelSync_withChannelID_delegate_(
+ None, channel_id, delegate
+ )
+
+ if isinstance(result, tuple):
+ status, channel = result
+ else:
+ status = result
+ channel = None
+
+ if status != 0:
+ raise ConnectionError(f"Failed to open RFCOMM channel: {status}")
+
+ # Wait for connection
+ deadline = time.time() + timeout
+ while not delegate.is_open and not delegate.error:
+ NSRunLoop.currentRunLoop().runMode_beforeDate_(
+ NSDefaultRunLoopMode,
+ NSDate.dateWithTimeIntervalSinceNow_(0.1)
+ )
+ if time.time() > deadline:
+ raise TimeoutError("Connection timeout")
+
+ if delegate.error:
+ raise ConnectionError(delegate.error)
+
+ # Small delay to let connection stabilize
+ time.sleep(0.5)
+
+ return channel
+
+
+def send_data(channel, data, chunk_size=512):
+ """Send data over RFCOMM channel in chunks."""
+ total = 0
+ for i in range(0, len(data), chunk_size):
+ chunk = data[i:i+chunk_size]
+ result = channel.writeSync_length_(chunk, len(chunk))
+ if result != 0:
+ raise IOError(f"Write failed at offset {i}: {result}")
+ total += len(chunk)
+ time.sleep(0.01) # Small delay between chunks
+ return total
+
+
+def print_image_m110(channel, image, media_type=10):
+ """Send image to M110/M220 printer via Bluetooth."""
+
+ # Convert image to 1-bit
+ img = image.convert('L')
+ img = ImageOps.invert(img)
+ img = img.convert('1')
+
+ # Set speed: ESC N 0x0d
+ send_data(channel, ESC + b'\x4e\x0d\x05')
+
+ # Set density: ESC N 0x04
+ send_data(channel, ESC + b'\x4e\x04\x0a')
+
+ # Set media type: 0x1f 0x11
+ send_data(channel, b'\x1f\x11' + media_type.to_bytes(1, 'little'))
+
+ # Print raster
+ width_bytes = (img.width + 7) // 8
+ height = img.height
+
+ # GS v 0
+ header = GS + b'v0\x00'
+ header += width_bytes.to_bytes(2, 'little')
+ header += height.to_bytes(2, 'little')
+ send_data(channel, header)
+
+ # Send image data
+ send_data(channel, img.tobytes())
+
+ # Footer
+ send_data(channel, b'\x1f\xf0\x05\x00')
+ send_data(channel, b'\x1f\xf0\x03\x00')
+
+ print(f"Sent {img.width}x{img.height} image to printer")
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Bluetooth printing for Phomemo printers')
+ parser.add_argument('image', nargs='?', help='Image file to print')
+ parser.add_argument('--list', '-l', action='store_true', help='List paired printers')
+ parser.add_argument('--address', '-a', help='Bluetooth address (XX:XX:XX:XX:XX:XX)')
+ parser.add_argument('--width', '-w', type=int, default=384, help='Max print width (default: 384)')
+ parser.add_argument('--media', '-m', type=int, default=10, help='Media type (10=gap, 11=continuous)')
+ parser.add_argument('--channel', '-c', type=int, default=1, help='RFCOMM channel (default: 1)')
+
+ args = parser.parse_args()
+
+ if not BLUETOOTH_AVAILABLE:
+ print(f"Error: Bluetooth not available: {BLUETOOTH_ERROR}", file=sys.stderr)
+ print("Install with: pip install pyobjc-framework-IOBluetooth", file=sys.stderr)
+ return 1
+
+ if args.list:
+ list_printers()
+ return 0
+
+ if not args.image:
+ parser.print_help()
+ return 1
+
+ # Find printer
+ if args.address:
+ # Use specified address
+ device = IOBluetoothDevice.deviceWithAddressString_(args.address.upper())
+ if not device:
+ print(f"Could not find device: {args.address}", file=sys.stderr)
+ return 1
+ printer_name = args.address
+ else:
+ # Find first Phomemo printer
+ printers = find_phomemo_printers()
+ if not printers:
+ print("No Phomemo printer found!", file=sys.stderr)
+ print("Use --list to see paired devices, or --address to specify manually")
+ return 1
+ device = printers[0]['device']
+ printer_name = printers[0]['name']
+
+ print(f"Using printer: {printer_name}")
+
+ # Load image
+ try:
+ img = Image.open(args.image)
+ except Exception as e:
+ print(f"Error loading image: {e}", file=sys.stderr)
+ return 1
+
+ # Resize if needed
+ if img.width > args.width:
+ ratio = args.width / img.width
+ new_height = int(img.height * ratio)
+ img = img.resize((args.width, new_height), Image.Resampling.LANCZOS)
+ print(f"Resized image to {img.width}x{img.height}")
+
+ # Connect
+ print(f"Connecting via Bluetooth (channel {args.channel})...")
+ try:
+ channel = connect_rfcomm(device, args.channel)
+ except Exception as e:
+ print(f"Connection failed: {e}", file=sys.stderr)
+ return 1
+
+ # Print
+ try:
+ print_image_m110(channel, img, args.media)
+ print("Print complete!")
+ except Exception as e:
+ print(f"Print failed: {e}", file=sys.stderr)
+ return 1
+ finally:
+ try:
+ channel.closeChannel()
+ except:
+ pass
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/macos/print-usb.py b/macos/print-usb.py
new file mode 100644
index 0000000..3145078
--- /dev/null
+++ b/macos/print-usb.py
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""
+Direct USB printing for Phomemo M110/M220 printers on macOS.
+
+Usage:
+ python3 print-usb.py
+ python3 print-usb.py --list # List connected printers
+"""
+
+import sys
+import os
+import argparse
+import usb.core
+import usb.util
+from PIL import Image, ImageOps
+
+# Known vendor IDs for Phomemo printers
+VENDOR_IDS = [
+ 0x0493, # MAG Technology (original Phomemo)
+ 0x0483, # Jieli Technology (some M220 models)
+]
+
+# Known product IDs by vendor
+PRODUCT_IDS = {
+ # MAG Technology (0x0493)
+ 0xb002: 'M02',
+ 0x8760: 'M110',
+ 0x8761: 'M110',
+ 0x8762: 'M120',
+ 0x8763: 'M220',
+ 0x8764: 'M421',
+ # Jieli Technology (0x0483)
+ 0x5740: 'M220',
+}
+
+# Printer commands
+ESC = b'\x1b'
+GS = b'\x1d'
+
+
+def find_printers():
+ """Find all connected Phomemo printers."""
+ printers = []
+
+ try:
+ for vendor_id in VENDOR_IDS:
+ devices = usb.core.find(find_all=True, idVendor=vendor_id)
+ for dev in devices:
+ # Check if it's a printer class device
+ is_printer = False
+ for cfg in dev:
+ intf = usb.util.find_descriptor(cfg, bInterfaceClass=7)
+ if intf:
+ is_printer = True
+ break
+
+ if not is_printer:
+ continue
+
+ model = PRODUCT_IDS.get(dev.idProduct, f'Unknown(0x{dev.idProduct:04x})')
+ try:
+ serial = usb.util.get_string(dev, dev.iSerialNumber)
+ except:
+ serial = 'Unknown'
+ printers.append({
+ 'device': dev,
+ 'model': model,
+ 'serial': serial,
+ 'product_id': dev.idProduct,
+ 'vendor_id': vendor_id,
+ })
+ except Exception as e:
+ print(f"Error scanning USB: {e}", file=sys.stderr)
+
+ return printers
+
+
+def list_printers():
+ """List all connected printers."""
+ printers = find_printers()
+ if not printers:
+ print("No Phomemo printers found.")
+ print("\nMake sure:")
+ print(" - Printer is connected via USB")
+ print(" - Printer is turned on")
+ print(" - libusb is installed: brew install libusb")
+ return
+
+ print(f"Found {len(printers)} printer(s):\n")
+ for i, p in enumerate(printers):
+ print(f" [{i}] {p['model']} (Serial: {p['serial']})")
+
+
+def open_printer(printer_info):
+ """Open USB connection to printer and return endpoints."""
+ dev = printer_info['device']
+
+ # Detach kernel driver if active
+ try:
+ if dev.is_kernel_driver_active(0):
+ dev.detach_kernel_driver(0)
+ except:
+ pass
+
+ # Set configuration
+ try:
+ dev.set_configuration()
+ except:
+ pass
+
+ # Find printer interface (class 7)
+ cfg = dev.get_active_configuration()
+ intf = usb.util.find_descriptor(cfg, bInterfaceClass=7)
+
+ if intf is None:
+ raise RuntimeError("Could not find printer interface")
+
+ # Find OUT endpoint
+ ep_out = usb.util.find_descriptor(
+ intf,
+ custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
+ )
+
+ if ep_out is None:
+ raise RuntimeError("Could not find OUT endpoint")
+
+ return dev, ep_out
+
+
+def print_image_m110(ep_out, image, media_type=10):
+ """Send image to M110/M220 printer."""
+
+ # Convert image to 1-bit
+ img = image.convert('L')
+ img = ImageOps.invert(img)
+ img = img.convert('1')
+
+ # Printer commands
+ def write(data):
+ ep_out.write(data)
+
+ # Set speed
+ write(ESC + b'\x4e\x0d\x05')
+
+ # Set density
+ write(ESC + b'\x4e\x04\x0a')
+
+ # Set media type
+ write(b'\x1f\x11' + media_type.to_bytes(1, 'little'))
+
+ # Print raster
+ width_bytes = (img.width + 7) // 8
+ height = img.height
+
+ write(GS + b'v0\x00') # Print raster command, mode 0
+ write(width_bytes.to_bytes(2, 'little'))
+ write(height.to_bytes(2, 'little'))
+ write(img.tobytes())
+
+ # Footer
+ write(b'\x1f\xf0\x05\x00')
+ write(b'\x1f\xf0\x03\x00')
+
+ print(f"Sent {img.width}x{img.height} image to printer")
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Direct USB printing for Phomemo printers')
+ parser.add_argument('image', nargs='?', help='Image file to print')
+ parser.add_argument('--list', '-l', action='store_true', help='List connected printers')
+ parser.add_argument('--width', '-w', type=int, default=384, help='Max print width in pixels (default: 384)')
+ parser.add_argument('--media', '-m', type=int, default=10, help='Media type (10=gap, 11=continuous, 38=marks)')
+
+ args = parser.parse_args()
+
+ if args.list:
+ list_printers()
+ return 0
+
+ if not args.image:
+ parser.print_help()
+ return 1
+
+ # Find printer
+ printers = find_printers()
+ if not printers:
+ print("No Phomemo printer found!", file=sys.stderr)
+ return 1
+
+ printer = printers[0]
+ print(f"Using printer: {printer['model']} (Serial: {printer['serial']})")
+
+ # Load image
+ try:
+ img = Image.open(args.image)
+ except Exception as e:
+ print(f"Error loading image: {e}", file=sys.stderr)
+ return 1
+
+ # Resize if needed
+ if img.width > args.width:
+ ratio = args.width / img.width
+ new_height = int(img.height * ratio)
+ img = img.resize((args.width, new_height), Image.Resampling.LANCZOS)
+ print(f"Resized image to {img.width}x{img.height}")
+
+ # Open printer
+ try:
+ dev, ep_out = open_printer(printer)
+ except Exception as e:
+ print(f"Error opening printer: {e}", file=sys.stderr)
+ return 1
+
+ # Print
+ try:
+ print_image_m110(ep_out, img, args.media)
+ print("Print complete!")
+ except Exception as e:
+ print(f"Error printing: {e}", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/requirements-linux.txt b/requirements-linux.txt
new file mode 100644
index 0000000..4ef4246
--- /dev/null
+++ b/requirements-linux.txt
@@ -0,0 +1,14 @@
+# Phomemo Tools - Linux Python Dependencies
+# Install with: pip3 install -r requirements-linux.txt
+
+# Core dependencies
+pillow>=9.0.0
+pyusb>=1.2.0
+
+# D-Bus for BlueZ Bluetooth support
+# Note: dbus-python typically comes from system packages:
+# Debian/Ubuntu: apt install python3-dbus
+# Fedora: dnf install python3-dbus
+#
+# If needed via pip:
+# dbus-python>=1.2.0
diff --git a/requirements-macos.txt b/requirements-macos.txt
new file mode 100644
index 0000000..14ddfc4
--- /dev/null
+++ b/requirements-macos.txt
@@ -0,0 +1,13 @@
+# Phomemo Tools - macOS Python Dependencies
+# Install with: pip3 install -r requirements-macos.txt
+
+# Core dependencies (same as requirements.txt)
+pillow>=9.0.0
+pyusb>=1.2.0
+
+# PyObjC for IOBluetooth support on macOS
+# These provide native Bluetooth RFCOMM connectivity
+pyobjc-core>=9.0
+pyobjc-framework-Cocoa>=9.0
+pyobjc-framework-IOBluetooth>=9.0
+pyobjc-framework-CoreBluetooth>=9.0
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..58657aa
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+# Phomemo Tools - Python Dependencies
+# Common requirements for all platforms
+
+pillow>=9.0.0
+pyusb>=1.2.0
diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh
new file mode 100755
index 0000000..fbcbc8c
--- /dev/null
+++ b/scripts/install-macos.sh
@@ -0,0 +1,212 @@
+#!/bin/bash
+#
+# macOS Installation Script for phomemo-tools
+#
+# This script installs phomemo-tools with full Bluetooth and USB support
+# on macOS, including Apple Silicon (M1/M2/M3) Macs.
+#
+# Requirements:
+# - macOS 11.0 (Big Sur) or later
+# - Homebrew (will be installed if missing)
+# - Python 3.9+
+#
+# Usage:
+# ./scripts/install-macos.sh
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}╔══════════════════════════════════════════════════════════════╗${NC}"
+echo -e "${BLUE}║ Phomemo Tools - macOS Installation Script ║${NC}"
+echo -e "${BLUE}╚══════════════════════════════════════════════════════════════╝${NC}"
+echo ""
+
+# Detect architecture
+ARCH=$(uname -m)
+if [ "$ARCH" = "arm64" ]; then
+ echo -e "${GREEN}✓${NC} Detected: Apple Silicon (arm64)"
+ HOMEBREW_PREFIX="/opt/homebrew"
+else
+ echo -e "${GREEN}✓${NC} Detected: Intel ($ARCH)"
+ HOMEBREW_PREFIX="/usr/local"
+fi
+
+# Check macOS version
+MACOS_VERSION=$(sw_vers -productVersion)
+echo -e "${GREEN}✓${NC} macOS Version: $MACOS_VERSION"
+echo ""
+
+# Function to check if a command exists
+command_exists() {
+ command -v "$1" &> /dev/null
+}
+
+# Function to install Homebrew if missing
+install_homebrew() {
+ if ! command_exists brew; then
+ echo -e "${YELLOW}→${NC} Installing Homebrew..."
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+ # Add Homebrew to PATH for Apple Silicon
+ if [ "$ARCH" = "arm64" ]; then
+ eval "$($HOMEBREW_PREFIX/bin/brew shellenv)"
+ fi
+ else
+ echo -e "${GREEN}✓${NC} Homebrew is already installed"
+ fi
+}
+
+# Function to check Python version
+check_python() {
+ if command_exists python3; then
+ PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
+ PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1)
+ PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
+
+ if [ "$PYTHON_MAJOR" -ge 3 ] && [ "$PYTHON_MINOR" -ge 9 ]; then
+ echo -e "${GREEN}✓${NC} Python $PYTHON_VERSION found"
+ return 0
+ fi
+ fi
+ return 1
+}
+
+# Step 1: Install Homebrew
+echo -e "${BLUE}Step 1: Checking Homebrew...${NC}"
+install_homebrew
+echo ""
+
+# Step 2: Install system dependencies
+echo -e "${BLUE}Step 2: Installing system dependencies...${NC}"
+brew install python3 libusb 2>/dev/null || true
+echo -e "${GREEN}✓${NC} System dependencies installed"
+echo ""
+
+# Step 3: Check Python version
+echo -e "${BLUE}Step 3: Checking Python version...${NC}"
+if ! check_python; then
+ echo -e "${RED}✗${NC} Python 3.9+ is required. Installing..."
+ brew install python@3.11
+fi
+echo ""
+
+# Step 4: Install Python dependencies
+echo -e "${BLUE}Step 4: Installing Python dependencies...${NC}"
+
+# Core dependencies
+echo -e "${YELLOW}→${NC} Installing core dependencies (pillow, pyusb)..."
+pip3 install --user pillow pyusb
+
+# PyObjC for Bluetooth support
+echo -e "${YELLOW}→${NC} Installing PyObjC for Bluetooth support..."
+pip3 install --user pyobjc-framework-IOBluetooth pyobjc-framework-CoreBluetooth pyobjc-core
+
+echo -e "${GREEN}✓${NC} Python dependencies installed"
+echo ""
+
+# Step 5: Get script directory and navigate to repo
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_DIR="$(dirname "$SCRIPT_DIR")"
+cd "$REPO_DIR"
+
+echo -e "${BLUE}Step 5: Building CUPS drivers...${NC}"
+echo -e "${YELLOW}→${NC} Working directory: $REPO_DIR"
+
+# Build PPD files
+cd cups
+if command_exists ppdc; then
+ make ppds
+ echo -e "${GREEN}✓${NC} PPD files built"
+else
+ echo -e "${YELLOW}!${NC} ppdc not found - using pre-built PPDs if available"
+fi
+echo ""
+
+# Step 6: Create CUPS directories
+echo -e "${BLUE}Step 6: Creating CUPS directories...${NC}"
+echo -e "${YELLOW}→${NC} This requires administrator privileges"
+
+sudo mkdir -p /usr/local/lib/cups/backend
+sudo mkdir -p /usr/local/lib/cups/backend/bluetooth
+sudo mkdir -p /usr/local/lib/cups/backend/usb
+sudo mkdir -p /usr/local/lib/cups/filter
+sudo mkdir -p /Library/Printers/PPDs/Contents/Resources/Phomemo
+
+echo -e "${GREEN}✓${NC} CUPS directories created"
+echo ""
+
+# Step 7: Install files
+echo -e "${BLUE}Step 7: Installing phomemo-tools...${NC}"
+sudo make install-darwin
+echo -e "${GREEN}✓${NC} Files installed"
+echo ""
+
+# Step 8: Configure CUPS
+echo -e "${BLUE}Step 8: Configuring CUPS...${NC}"
+
+CUPS_CONF="/etc/cups/cups-files.conf"
+SERVERBIN_LINE="ServerBin /usr/local/lib/cups"
+
+if grep -q "^ServerBin" "$CUPS_CONF" 2>/dev/null; then
+ if grep -q "^$SERVERBIN_LINE" "$CUPS_CONF"; then
+ echo -e "${GREEN}✓${NC} CUPS already configured for custom backend path"
+ else
+ echo -e "${YELLOW}!${NC} Warning: ServerBin is already set in cups-files.conf"
+ echo -e " You may need to manually add: $SERVERBIN_LINE"
+ fi
+else
+ echo -e "${YELLOW}→${NC} Adding ServerBin configuration..."
+ echo "$SERVERBIN_LINE" | sudo tee -a "$CUPS_CONF" > /dev/null
+ echo -e "${GREEN}✓${NC} CUPS configuration updated"
+fi
+echo ""
+
+# Step 9: Restart CUPS
+echo -e "${BLUE}Step 9: Restarting CUPS service...${NC}"
+sudo launchctl stop org.cups.cupsd 2>/dev/null || true
+sleep 1
+sudo launchctl start org.cups.cupsd
+echo -e "${GREEN}✓${NC} CUPS restarted"
+echo ""
+
+# Done!
+echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}║ Installation Complete! ║${NC}"
+echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+echo ""
+echo -e "${BLUE}Next Steps:${NC}"
+echo ""
+echo "1. Pair your Phomemo printer via System Settings → Bluetooth"
+echo ""
+echo "2. Add the printer using one of these methods:"
+echo ""
+echo " ${YELLOW}GUI:${NC}"
+echo " - Open System Settings → Printers & Scanners"
+echo " - Click '+' to add a printer"
+echo " - Your Phomemo printer should appear in the list"
+echo ""
+echo " ${YELLOW}Command Line (Bluetooth):${NC}"
+echo " sudo lpadmin -p PhomemoM02 -E \\"
+echo " -v phomemo://AABBCCDDEEFF \\"
+echo " -P /Library/Printers/PPDs/Contents/Resources/Phomemo/Phomemo-M02.ppd.gz"
+echo ""
+echo " ${YELLOW}Command Line (USB):${NC}"
+echo " sudo lpadmin -p PhomemoM02 -E \\"
+echo " -v serial:/dev/cu.usbmodem* \\"
+echo " -P /Library/Printers/PPDs/Contents/Resources/Phomemo/Phomemo-M02.ppd.gz"
+echo ""
+echo "3. Test printing:"
+echo " echo 'Hello World' | lp -d PhomemoM02 -o media=w50h60 -"
+echo ""
+echo -e "${BLUE}Troubleshooting:${NC}"
+echo " - Check CUPS logs: tail -f /var/log/cups/error_log"
+echo " - List printers: lpstat -p -d"
+echo " - Run discovery: /usr/local/lib/cups/backend/phomemo"
+echo ""