Python library#
phomemo lets you drive a Phomemo thermal printer from any asyncio program: scan, connect, print images, read the sensors. It is built on bleak and Pillow, and the bridge and printer app run on top of it (see Driver stack).
Every class and function is listed in the API reference. This page walks you through the parts you need first.
Install#
pip install phomemo
Or with uv, or on Fedora from the COPR repository passionatebytes/phomemo-driver:
uv add phomemo
sudo dnf install python3-phomemo
You need Python 3.13 or later and a Bluetooth adapter. Linux with BlueZ is tested; bleak also runs on macOS and Windows.
Quick start#
import asyncio
from phomemo import Printer, Density, ImageFit, discover
async def main():
devices = await discover(name_pattern=r"^M08F")
if not devices:
print("No printers found")
return
async with Printer("m08f", devices[0]) as printer:
await printer.connect()
await printer.wait_for_ready(timeout=60)
await printer.print_image("photo.png", density=Density.MEDIUM, fit=ImageFit.FIT_PAGE)
await printer.eject_paper()
asyncio.run(main())
Here is what happens:
discover()scans for printers that advertise a name starting withM08F.Printer("m08f", ...)picks them08fprofile;connect()brings up the connection.wait_for_ready()blocks until the lid is closed and a sheet is seated.print_image()scales the image to the sheet, dithers it and prints it.eject_paper()feeds the sheet out.
Tip
Switch the printer on before you scan. While another host holds a connection, the printer does not advertise and your scan will not find it.
Scan for printers#
from phomemo import discover
devices = await discover() # every BLE device in range
devices = await discover(name_pattern=r"^M08F") # M08F printers only
discover() scans for timeout seconds (default 5) and returns BLEDevice objects sorted by name. name_pattern is a regular expression searched in the advertised name.
Note
The M08F uses no pairing: any host in range can connect.
Connect#
from phomemo import Printer
async with Printer("m08f", "AA:BB:CC:DD:EE:FF") as printer:
await printer.connect()
...
# disconnected here
Printer takes a profile name ("m08f" is the default) or a PrinterProfile, plus the device:
a
BLEDevicefromdiscover(),an address string,
or nothing, to find a printer by its advertised name on
connect().
An address string is resolved through a short targeted scan first, because BlueZ only connects to a device it has seen advertising recently. connect(discovery_timeout=...) bounds that scan.
Warning
A connection the printer drops on its own surfaces as ConnectionError. Call reconnect() to bring it up again.
Print an image#
from phomemo import Density, DitherMode, ImageFit
await printer.print_image(
"photo.png",
density=Density.HIGH,
fit=ImageFit.FIT_PAGE,
dither=DitherMode.FLOYD_STEINBERG,
)
print_image() accepts a file path or a PIL Image and handles the rest: resize, dither, raster encoding, transfer and waiting for the motor to stop.
Fit#
|
Effect |
|---|---|
|
Scale to the head width |
|
Scale to the head width, bounded in height by the profile’s |
|
See the API reference |
Tip
Use FIT_PAGE for anything taller than a sheet. It guarantees the image cannot run the sheet out mid-print.
Density and dithering#
|
M08F |
|---|---|
|
1 |
|
5 |
|
6 |
The values come from the model manifest; a model without them has no density control.
DitherMode.FLOYD_STEINBERG (default) suits photos; DitherMode.THRESHOLD splits at 128 without dithering.
Print a bitmap#
Want full control over the pixels? Prepare the bitmap and send it yourself:
from phomemo import prepare_image, image_to_bitmap
from PIL import Image
img = Image.open("photo.png")
processed = prepare_image(img, target_width=printer.profile.print_width_px)
bitmap = image_to_bitmap(processed)
await printer.print_bitmap(bitmap, height=processed.size[1])
print_bitmap() logs a warning when the raster is taller than max_rows. For other sheet lengths, profile.rows_for(length_mm) gives the printable rows. printer.rows_sent reports progress while the transfer runs.
When a print stops early#
Situation |
You get |
|---|---|
The |
|
The sheet runs out with more than a quarter of the page unsent, or the printer stops answering after the motor stop |
|
The lid is open at the motor stop |
|
A BLE failure, including a dropped connection |
|
On abort, the band in progress is padded with white rows so the printer’s parser stays in sync.
Note
Raster data goes out with ATT write-with-response as one continuous stream, so the printer paces the transfer itself (roughly 6.5 to 7.4 KiB/s). You never need to add delays. The Protocol reference explains why.
Page rasters and tone modes#
from PIL import Image
from phomemo.imaging import ToneMode, prepare_page
page = Image.open("page.png") # e.g. a rendered 8-bit gray page
sheet = prepare_page(page, printer.profile, length_mm=297.0, tone=ToneMode.DIFFUSION)
prepare_page() maps a page raster from a print system (mode L with 0 = black, or mode 1) onto the sheet: an optional quarter turn with rotate=True (A5 is fed long edge first), centring on the head width, dropping the rows in the unprintable top lead, and clipping to the sheet. Gray input is converted with a tone mode; 1-bit input keeps its pixels.
|
Result |
|---|---|
|
Split at mid gray: true black and white, crisp text |
|
8x8 Bayer pattern: even tone |
|
Floyd-Steinberg error diffusion: photos |
Move the paper#
await printer.feed(20) # 20 dot rows, about 2.5 mm
await printer.wait_for_ready(timeout=300, settle=3.0)
ejected = await printer.eject_paper()
wait_for_ready()blocks until the lid is closed and a sheet is loaded and seated, else raisesTimeoutError.settlecaps the wait for the feed motor after the sensor sees the sheet. A printer that stops answering queries is reconnected once.eject_paper()keeps two 15 mm feeds queued until the paper-absent event arrives, then a 20 mm feed clears the rollers. It returnsFalseif the sheet is still there aftermax_feeds(64, about 1 m).
Listen to events#
from phomemo import DeviceEvent, LidEvent, PaperEvent, MotorStopEvent
def on_event(event: DeviceEvent):
match event:
case LidEvent(lid=lid):
print(f"Lid: {lid.value}")
case PaperEvent(paper=paper):
print(f"Paper: {paper.value}")
case MotorStopEvent():
print("Print complete")
printer.on_event(on_event)
Your callback receives typed events as the printer sends them. The other types are BatteryEvent, FirmwareEvent, SerialEvent and TimerEvent.
Read device information#
battery = await printer.query_battery() # int 0-100, or None
firmware = await printer.query_firmware() # str such as "1.1.3", or None
lid = await printer.query_lid() # LidState.CLOSED / .OPEN
paper = await printer.query_paper() # PaperState.PRESENT / .ABSENT
minutes = await printer.query_auto_off() # auto-off timer in minutes
info = await printer.query_device_info() # everything at once
print(info.battery, info.lid, info.paper, info.firmware, info.serial)
await printer.set_auto_off_timer(15) # 5-minute steps, 0 = never
A query that gets no answer returns None.
Warning
Paper state is unreliable while the lid is open. query_device_info() takes care of this and queries paper only when the lid is closed.
Profiles and model manifests#
A profile (PrinterProfile) holds everything model-specific. The built-in profiles are built at import from the model manifests, the same files the printer app embeds.
Tip
Adding a printer model? Write a model manifest instead of code; every part of the stack picks it up. See Printer models.
You can also load or register profiles in your own program:
from pathlib import Path
from phomemo import Printer, register_profile
from phomemo.registry import PrinterProfile, load_manifest, profile_for_name
register_profile(load_manifest(Path("my-models/m02s"))) # directory with model.json
register_profile(PrinterProfile(
name="m02s",
ble_name_pattern=r"^M02S",
print_width_px=576,
sheet_length_mm=None, # continuous roll
))
profile_for_name("M08F") # profile whose ble_name_pattern matches, or None
async with Printer("m02s") as printer:
...
Function |
Does |
|---|---|
|
Builds a profile from a model manifest directory; a missing or mistyped field raises |
|
Adds a profile, replacing one of the same name |
|
Looks a profile up; |
|
Sorted profile names |
|
The profile whose |
Profile fields#
Field |
Default |
Meaning |
|---|---|---|
|
required |
Lower-case model identifier ( |
|
required |
Regular expression matched against the advertised name |
|
required |
Head width in dots, a multiple of 8 |
|
|
Resolution |
|
|
ATT Write Request; the printer paces the stream. |
|
|
Delay between writes; only meaningful without responses |
|
|
Write size when the MTU cannot be read |
|
|
Derive the write size from the negotiated MTU |
|
|
Rows per |
|
|
Sheet length in the feed direction ( |
|
|
Unprintable lead at the top of a sheet (sensor-to-head distance) |
|
|
Kept free at the sheet end (M08F: 5 mm) |
|
|
|
|
|
Standard ESC/POS queries answer |
|
|
GATT service and characteristics |
|
|
Names as users see them |
|
|
PWG names of the sheet sizes, and the default |
|
derived |
Rows between the margins of the default sheet; A4: 2253 |
Next steps#
The full API: API reference.
How the printer behaves on the wire: Protocol reference.
Found a gap or have a new printer model? See Contributing.