# This file is part of phomemo.
# Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
#
# phomemo is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# phomemo is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with phomemo. If not, see <https://www.gnu.org/licenses/>.
"""High-level async printer client.
The ``Printer`` class is the primary developer-facing API. It composes
the transport, protocol, event, and imaging modules into a single
coherent interface for controlling Phomemo thermal printers.
Typical usage::
from phomemo import Printer, Density
async with Printer("m08f", "AA:BB:CC:DD:EE:FF") as printer:
await printer.connect()
# Check device state before printing
info = await printer.query_device_info()
print(f"Battery: {info.battery}%, Lid: {info.lid}, Paper: {info.paper}")
# Wait for a sheet, print, feed it out
await printer.wait_for_ready()
await printer.print_image("photo.png", density=Density.MEDIUM)
await printer.eject_paper()
"""
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Self
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.exc import BleakError
from PIL import Image
from phomemo.discovery import discover
from phomemo.events import (
BatteryEvent,
DeviceEvent,
FirmwareEvent,
LidEvent,
LidState,
MotorStopEvent,
PaperEvent,
PaperState,
SerialEvent,
TimerEvent,
parse_notification,
)
from phomemo.imaging import (
DitherMode,
ImageFit,
image_to_bitmap,
prepare_image,
)
from phomemo.protocol import (
Density,
QueryCommand,
ScaleMode,
encode_auto_off_timer,
encode_density,
encode_feed_lines,
encode_init,
encode_raster_header,
)
from phomemo.registry import PrinterProfile, get_profile
from phomemo.transport import BleTransport
logger = logging.getLogger(__name__)
AUTO_OFF_MAX_MINUTES = 1275 # one byte counting 5-minute steps
EventCallback = Callable[[DeviceEvent], None]
ProgressCallback = Callable[[int, int], None]
[docs]
class PrintAborted(RuntimeError):
"""A raster transfer stopped before the page was complete.
Attributes:
reason: Short human-readable cause, e.g. ``"cancelled"``.
"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
[docs]
class SheetRanOut(PrintAborted):
"""The sheet left the paper sensor before the raster was printed."""
[docs]
@dataclass(frozen=True, slots=True)
class DeviceInfo:
"""Snapshot of device state collected from query responses.
Attributes:
battery: Battery percentage (0-100), or None if not queried.
lid: Lid state, or None if not queried.
paper: Paper state, or None if not queried.
firmware: Firmware version string, or None if not queried.
serial: Serial number string, or None if not queried.
auto_off_minutes: Auto-off timer in minutes (0 = disabled),
or None if not queried.
"""
battery: int | None = None
lid: LidState | None = None
paper: PaperState | None = None
firmware: str | None = None
serial: str | None = None
auto_off_minutes: int | None = None
[docs]
class Printer:
"""Async BLE client for Phomemo thermal printers.
Manages the full lifecycle: BLE connection, device queries, image
preparation, raster printing, and paper transport. Designed to be
used as an async context manager.
Args:
profile: Either a profile name string (e.g. ``"m08f"``) or
a ``PrinterProfile`` instance.
address: BLE MAC address (e.g. ``"AA:BB:CC:DD:EE:FF"``), resolved
by a targeted scan at ``connect()`` time, or a ``BLEDevice``
from ``discover()``, used as is. If omitted, ``connect()``
auto-discovers a nearby device matching the profile's
``ble_name_pattern``.
"""
def __init__(
self,
profile: str | PrinterProfile = "m08f",
address: str | BLEDevice | None = None,
) -> None:
if isinstance(profile, str):
self._profile = get_profile(profile)
else:
self._profile = profile
self._address = address
self._transport = BleTransport(self._profile)
self._event_callbacks: list[EventCallback] = []
self._waiters: list[tuple[type[DeviceEvent], asyncio.Future[DeviceEvent]]] = []
self._rows_sent = 0
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def profile(self) -> PrinterProfile:
"""The active printer hardware profile."""
return self._profile
@property
def is_connected(self) -> bool:
"""Whether the BLE connection is active."""
return self._transport.is_connected
@property
def address(self) -> str | None:
"""The printer's BLE address, once known."""
device = self._address
if device is None:
return None
return getattr(device, "address", None) or str(device)
@property
def chunk_bytes(self) -> int:
"""Bytes per BLE write on the current connection."""
return self._transport.chunk_bytes
@property
def rows_sent(self) -> int:
"""Raster rows handed to the transport in the current transfer, else 0."""
return self._rows_sent
# ------------------------------------------------------------------
# Event handling
# ------------------------------------------------------------------
[docs]
def on_event(self, callback: EventCallback) -> None:
"""Register a listener for parsed device events.
Callbacks receive typed event dataclasses (``LidEvent``,
``PaperEvent``, ``BatteryEvent``, ``MotorStopEvent``, etc.) as
they arrive from the printer.
Args:
callback: Called with each parsed ``DeviceEvent``.
"""
self._event_callbacks.append(callback)
def _dispatch_event(self, event: DeviceEvent) -> None:
"""Route a parsed event to callbacks and any active typed waiters.
Events are delivered to the first waiter whose registered type
matches the event. Unmatched events are still delivered to
callbacks but do not consume any waiter.
Args:
event: The parsed device event.
"""
for cb in self._event_callbacks:
try:
cb(event)
except Exception:
logger.exception("Event callback %r failed for %r", cb, event)
for event_type, future in self._waiters:
if not future.done() and isinstance(event, event_type):
future.set_result(event)
break
def _on_notification(self, data: bytes) -> None:
"""Handle raw BLE notification data from ff01.
Args:
data: Raw bytes from the notification.
"""
for event in parse_notification(data):
self._dispatch_event(event)
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
[docs]
async def connect(self, discovery_timeout: float = 5.0) -> None:
"""Connect to the printer.
The transport always receives a ``BLEDevice`` from a live scan:
BlueZ can only connect to a device it has seen advertising recently,
so a bare MAC address is resolved through a targeted scan first.
Without an ``address``, scans for a nearby device whose advertised
name matches the profile's ``ble_name_pattern``, connects to it and
keeps its address for later connects; several matches are refused
rather than picking one.
Args:
discovery_timeout: Scan duration in seconds, for both name-based
discovery and MAC resolution. Ignored if a ``BLEDevice`` was
provided at instantiation.
Raises:
ConnectionError: If the connection fails, if a name scan finds
no matching device or several, or if the given MAC address is
not seen within ``discovery_timeout``.
RuntimeError: If already connected.
"""
device = self._address
if device is None:
pattern = self._profile.ble_name_pattern
devices = await discover(name_pattern=pattern, timeout=discovery_timeout)
if not devices:
raise ConnectionError(
f"No device found matching profile {self._profile.name!r} "
f"(pattern {pattern!r}) within {discovery_timeout}s"
)
if len(devices) > 1:
found = ", ".join(sorted(d.address for d in devices))
raise ConnectionError(
f"Several printers in range ({found}); choose one by address"
)
device = devices[0]
logger.info("Discovered %s at %s", device.name, device.address)
# Later connects resolve the address by a fresh scan; BlueZ may
# have dropped this device object by then.
self._address = device.address
elif isinstance(device, str):
try:
found = await BleakScanner.find_device_by_address(
device, timeout=discovery_timeout
)
except BleakError as exc:
raise ConnectionError(f"Scan for {device} failed: {exc}") from exc
if found is None:
raise ConnectionError(
f"Device {device} not found within {discovery_timeout}s"
)
device = found
await self._transport.connect(
device,
on_event=self._on_notification,
)
[docs]
async def disconnect(self) -> None:
"""Disconnect from the printer.
Safe to call even if not connected.
"""
await self._transport.disconnect()
# ------------------------------------------------------------------
# Low-level commands
# ------------------------------------------------------------------
[docs]
async def initialize(self) -> None:
"""Send ``ESC @`` to reset the printer to its default state.
Should precede standalone ESC-prefix commands to prevent the
BLE bridge chip from intercepting them.
"""
logger.debug("Sending ESC @")
await self._transport.write(encode_init())
[docs]
async def set_density(self, level: Density) -> None:
"""Set print concentration via ``ESC N 04``.
Persists until the next ``ESC @`` reset. A model without density
control ignores the call.
Args:
level: Desired concentration level.
"""
value = self._profile.density_value(level)
if value is None:
logger.debug("Density %s ignored: no density control", level)
return
logger.debug("Setting density: %s (%d)", level, value)
await self._transport.write(encode_density(value))
[docs]
async def feed(self, lines: int = 20) -> None:
"""Feed paper by the given number of lines via ``ESC d``.
Each line is approximately one dot row (0.125mm at 203 DPI).
Args:
lines: Number of lines to feed (1-255).
"""
logger.debug("Feeding %d lines", lines)
await self._transport.write(encode_feed_lines(lines))
[docs]
async def eject_paper(
self,
max_feeds: int = 64,
step_rows: int = 120,
ahead: int = 2,
feed_seconds: float = 0.7,
clear_rows: int = 160,
) -> bool:
"""Feed the sheet out until the sensor reports it gone, then clear the rollers.
Feeds of ``step_rows`` (15mm by default) are kept ``ahead`` deep in
the printer's queue so the motor runs without pauses: one more goes
out whenever ``feed_seconds`` (about one section at the measured
feed speed) pass without the paper-absent event. On the event, one
feed of ``clear_rows`` pushes the sheet out of the rollers. A sheet
already past the sensor, or a printer that does not answer the
initial query, gets three feeds of ``clear_rows`` blind.
Args:
max_feeds: Maximum number of feeds before giving up (about 1m).
step_rows: Rows per feed.
ahead: Feeds queued before waiting for the sensor event.
feed_seconds: Wait per feed for the sensor event before queuing
the next one; longer than the feed itself stalls the motion,
shorter queues ahead of the motor.
clear_rows: Rows fed once the sensor is clear.
Returns:
``True`` once the sheet has left the sensor, ``False`` if it is
still present after ``max_feeds`` feeds.
"""
absent = asyncio.Event()
def watch_paper(event: DeviceEvent) -> None:
if isinstance(event, PaperEvent) and event.paper == PaperState.ABSENT:
absent.set()
self._event_callbacks.append(watch_paper)
try:
paper = await self.query_paper(timeout=2.0)
if paper != PaperState.PRESENT:
for _ in range(3):
await self.feed(clear_rows)
logger.debug("eject_paper: no sheet at the sensor, rollers cleared")
return True
started = asyncio.get_running_loop().time()
sent = 0
while sent < min(ahead, max_feeds):
await self.feed(step_rows)
sent += 1
while True:
try:
await asyncio.wait_for(absent.wait(), timeout=feed_seconds)
except TimeoutError:
if sent >= max_feeds:
break
await self.feed(step_rows)
sent += 1
continue
await self.feed(clear_rows)
logger.info(
"eject_paper: sensor clear after %d feeds of %d rows, %.1f s",
sent,
step_rows,
asyncio.get_running_loop().time() - started,
)
return True
logger.warning("eject_paper: paper still present after %d feeds", max_feeds)
return False
finally:
self._event_callbacks.remove(watch_paper)
[docs]
async def set_auto_off_timer(self, minutes: int) -> None:
"""Set the auto-off timer.
Args:
minutes: Timeout in minutes, a multiple of 5 up to
``AUTO_OFF_MAX_MINUTES``. 0 disables the timer.
Raises:
ValueError: If minutes is out of range or not a multiple of 5.
"""
if not 0 <= minutes <= AUTO_OFF_MAX_MINUTES or minutes % 5 != 0:
raise ValueError(
f"Timer must be a multiple of 5 between 0 and "
f"{AUTO_OFF_MAX_MINUTES}, got {minutes}"
)
logger.debug("Setting auto-off timer: %d minutes", minutes)
value = minutes // 5
await self.initialize()
await self._transport.write(encode_auto_off_timer(value))
# ------------------------------------------------------------------
# Device queries
# ------------------------------------------------------------------
async def _wait_for(
self,
event_type: type[DeviceEvent],
timeout: float,
) -> DeviceEvent | None:
"""Wait for the next event of a specific type.
Spontaneous events of other types are ignored by this waiter
(they still reach registered callbacks).
Args:
event_type: The event class to wait for.
timeout: Maximum seconds to wait.
Returns:
The matched event, or None on timeout.
"""
loop = asyncio.get_running_loop()
future: asyncio.Future[DeviceEvent] = loop.create_future()
entry = (event_type, future)
self._waiters.append(entry)
try:
return await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
return None
finally:
self._waiters.remove(entry)
async def _query(
self,
command: QueryCommand,
event_type: type[DeviceEvent],
timeout: float = 1.0,
) -> DeviceEvent | None:
"""Send a query and wait for the matching response event.
Returns as soon as the expected event type arrives, rather than
waiting for the full timeout. Spontaneous events of other types
do not interfere.
Args:
command: The query command bytes.
event_type: Expected response event class.
timeout: Maximum seconds to wait for a response.
Returns:
The matched event, or None on timeout.
"""
logger.debug("Query %s (timeout=%.1fs)", command.hex(), timeout)
await self._transport.write(command)
return await self._wait_for(event_type, timeout)
[docs]
async def query_battery(self, timeout: float = 1.0) -> int | None:
"""Query the current battery level.
Args:
timeout: Seconds to wait for a response.
Returns:
Battery percentage (0-100), or None if no response.
"""
event = await self._query(QueryCommand.BATTERY, BatteryEvent, timeout)
return event.percent if event else None
[docs]
async def query_firmware(self, timeout: float = 1.0) -> str | None:
"""Query the firmware version.
Args:
timeout: Seconds to wait for a response.
Returns:
Version string (e.g. ``"1.1.3"``), or None if no response.
"""
event = await self._query(QueryCommand.FIRMWARE, FirmwareEvent, timeout)
if not event:
return None
assert isinstance(event, FirmwareEvent)
return f"{event.major}.{event.minor}.{event.patch}"
[docs]
async def query_serial(self, timeout: float = 1.0) -> str | None:
"""Query the device serial number (ASCII form).
Args:
timeout: Seconds to wait for a response.
Returns:
Serial number string, or None if no response.
"""
event = await self._query(QueryCommand.SERIAL_ASCII, SerialEvent, timeout)
return event.value if event else None
[docs]
async def query_lid(self, timeout: float = 1.0) -> LidState | None:
"""Query the lid state.
Args:
timeout: Seconds to wait for a response.
Returns:
``LidState.CLOSED`` or ``LidState.OPEN``, or None.
"""
event = await self._query(QueryCommand.LID_STATE, LidEvent, timeout)
return event.lid if event else None
[docs]
async def query_paper(self, timeout: float = 1.0) -> PaperState | None:
"""Query the paper sensor state.
**Important:** Paper state is unreliable when the lid is open.
Always query lid state first.
Args:
timeout: Seconds to wait for a response.
Returns:
``PaperState.PRESENT`` or ``PaperState.ABSENT``, or None.
"""
event = await self._query(QueryCommand.PAPER_STATE, PaperEvent, timeout)
return event.paper if event else None
[docs]
async def query_auto_off(self, timeout: float = 1.0) -> int | None:
"""Query the auto-off timer.
Args:
timeout: Seconds to wait for the response.
Returns:
Timeout in minutes (0 = disabled), or ``None`` if the printer
did not answer.
"""
event = await self._query(QueryCommand.DEVICE_TIMER, TimerEvent, timeout)
return event.minutes if isinstance(event, TimerEvent) else None
[docs]
async def query_device_info(self, timeout: float = 1.0) -> DeviceInfo:
"""Query all available device information.
Sends battery, firmware, serial, lid, paper, and timer queries
sequentially. Each query returns as soon as the device responds
(typically ~70ms), so the full round-trip is well under 1s.
Paper state is discarded if the lid is open (sensor unreliable).
Args:
timeout: Per-query timeout in seconds.
Returns:
A ``DeviceInfo`` with all available fields populated.
"""
battery = await self.query_battery(timeout)
firmware = await self.query_firmware(timeout)
serial = await self.query_serial(timeout)
lid = await self.query_lid(timeout)
# Paper sensor is unreliable with lid open — only query if closed
paper = None
if lid == LidState.CLOSED:
paper = await self.query_paper(timeout)
auto_off_minutes = await self.query_auto_off(timeout)
return DeviceInfo(
battery=battery,
lid=lid,
paper=paper,
firmware=firmware,
serial=serial,
auto_off_minutes=auto_off_minutes,
)
# ------------------------------------------------------------------
# Pre-print readiness check
# ------------------------------------------------------------------
[docs]
async def check_ready(self, timeout: float = 1.0) -> tuple[bool, str]:
"""Check whether the printer is ready to print.
Queries lid and paper state. Returns a tuple of ``(ready, reason)``
where ``ready`` is ``True`` if the printer can accept a print job.
Args:
timeout: Per-query timeout in seconds.
Returns:
``(True, "ready")`` or ``(False, reason_string)``.
"""
lid = await self.query_lid(timeout)
if lid is None:
logger.debug("check_ready: lid query timed out")
return False, "Could not query lid state"
if lid == LidState.OPEN:
logger.debug("check_ready: lid is open")
return False, "Lid is open"
paper = await self.query_paper(timeout)
if paper is None:
logger.debug("check_ready: paper query timed out")
return False, "Could not query paper state"
if paper == PaperState.ABSENT:
logger.debug("check_ready: no paper loaded")
return False, "No paper loaded"
logger.debug("check_ready: ready")
return True, "ready"
[docs]
async def reconnect(self, discovery_timeout: float = 5.0) -> None:
"""Drop and re-establish the BLE connection to the same device.
A printer that ran a raster past the end of the sheet stops
answering queries and discards further print data until the connection
is re-established.
Args:
discovery_timeout: Scan budget if the device has to be found again.
"""
logger.info("Reconnecting to printer")
await self.disconnect()
await self.connect(discovery_timeout)
[docs]
async def wait_for_ready(
self,
timeout: float = 60.0,
poll: float = 0.5,
settle: float = 3.0,
mute_threshold: int = 3,
) -> None:
"""Block until a sheet is loaded and seated at the print head.
Polls ``check_ready`` so a caller can start a page job and let the
user insert the sheet afterwards. The paper sensor reports the sheet
as soon as its edge arrives; the feed motor keeps pulling for about
two more seconds and then the printer repeats the paper-present
event. ``settle`` waits for that repeat (capped) so raster data is
not sent while the sheet is still moving.
``mute_threshold`` consecutive query timeouts mean the printer is in
the error state left by a sheet overrun; the connection is dropped
and re-established once, which clears it.
Args:
timeout: Maximum seconds to wait for a sheet.
poll: Seconds between readiness checks.
settle: Maximum seconds to wait for the feed to complete after
the sheet is detected; ``0`` sends as soon as it is detected.
mute_threshold: Consecutive query timeouts before a reconnect.
Raises:
TimeoutError: With the last readiness reason as message.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
timeouts = 0
reconnected = False
while True:
ready, reason = await self.check_ready()
if ready:
break
timeouts = timeouts + 1 if reason.startswith("Could not query") else 0
if timeouts >= mute_threshold and not reconnected:
reconnected = True
timeouts = 0
await self.reconnect()
continue
if loop.time() >= deadline:
raise TimeoutError(reason)
await asyncio.sleep(poll)
if settle > 0:
logger.debug("wait_for_ready: sheet detected, settling up to %.1fs", settle)
event = await self._wait_for(PaperEvent, settle)
logger.debug("wait_for_ready: settle ended with %s", event)
# ------------------------------------------------------------------
# Raster printing
# ------------------------------------------------------------------
def _build_raster_stream(
self,
bitmap: bytes,
height: int,
density: Density | None = None,
mode: ScaleMode = ScaleMode.NORMAL,
feed_lines: int = 20,
) -> tuple[bytes, list[tuple[int, int, int, int]]]:
"""Assemble the print job and a map of its raster bands.
The stream is ``ESC @``, the optional density command, one
``GS v 0`` band per ``band_height`` rows, and the trailing feed.
Args:
bitmap: Packed 1-bit bitmap bytes (row-major).
height: Total number of rows in the bitmap.
density: Print concentration, or None to skip.
mode: GS v 0 scaling mode.
feed_lines: Paper feed lines after the raster data.
Returns:
The byte stream and, per band, ``(header_start, data_start,
data_end, rows)``: the band's offsets within the stream.
"""
width_bytes = self._profile.width_bytes
band_height = self._profile.band_height
parts: list[bytes] = [encode_init()]
value = self._profile.density_value(density) if density is not None else None
if value is not None:
parts.append(encode_density(value))
offset = sum(len(part) for part in parts)
bands: list[tuple[int, int, int, int]] = []
row = 0
while row < height:
band_rows = min(band_height, height - row)
header = encode_raster_header(width_bytes, band_rows, mode)
data = bitmap[row * width_bytes : (row + band_rows) * width_bytes]
parts.append(header)
parts.append(data)
data_start = offset + len(header)
bands.append((offset, data_start, data_start + len(data), band_rows))
offset = data_start + len(data)
row += band_rows
if feed_lines > 0:
parts.append(encode_feed_lines(min(feed_lines, 255)))
return b"".join(parts), bands
@staticmethod
def _rows_before(
offset: int, bands: list[tuple[int, int, int, int]], width_bytes: int
) -> int:
"""Rows whose bytes lie entirely before ``offset`` in the stream."""
rows = 0
for _header_start, data_start, data_end, band_rows in bands:
if offset >= data_end:
rows += band_rows
elif offset > data_start:
rows += (offset - data_start) // width_bytes
return rows
async def _pad_band(
self, stream: bytes, offset: int, bands: list[tuple[int, int, int, int]]
) -> None:
"""Complete the band in progress at ``offset`` with white rows.
A band is atomic for the printer's parser: once any of its header is
out, every byte of it must follow or later commands are read as
pixels. At a band boundary nothing is owed.
"""
for header_start, data_start, data_end, _rows in bands:
if header_start < offset < data_end:
filler = stream[offset:data_start] + bytes(
data_end - max(offset, data_start)
)
chunk = self._transport.chunk_bytes
for start in range(0, len(filler), chunk):
await self._transport.write(filler[start : start + chunk])
return
async def _transfer(
self,
stream: bytes,
bands: list[tuple[int, int, int, int]],
height: int,
abort: asyncio.Event | None,
absent: asyncio.Event,
lid_open: asyncio.Event,
short_sheet_fraction: float,
on_progress: ProgressCallback | None,
) -> None:
"""Send the stream in connection-sized chunks without pausing at band ends.
An open lid ends the transfer at once: the printer discards data in
that state. The band in progress is still completed with white rows
so the parser is in sync when the lid closes.
"""
chunk = self._transport.chunk_bytes
width_bytes = self._profile.width_bytes
total = -(-len(stream) // chunk)
# Past the last band only the trailing feed command remains; a stop
# there would leave the printer waiting for the rest of it.
tail_start = bands[-1][2] if bands else len(stream)
offset = 0
sent = 0
while offset < len(stream):
if abort is not None and abort.is_set() and offset < tail_start:
await self._pad_band(stream, offset, bands)
raise PrintAborted("cancelled")
if lid_open.is_set() and offset < tail_start:
await self._pad_band(stream, offset, bands)
raise PrintAborted("lid opened")
unsent = height - self._rows_sent
if absent.is_set() and height and unsent / height > short_sheet_fraction:
raise SheetRanOut("paper ran out")
piece = stream[offset : offset + chunk]
await self._transport.write(piece)
offset += len(piece)
sent += 1
self._rows_sent = self._rows_before(offset, bands, width_bytes)
if on_progress is not None:
try:
on_progress(sent, total)
except Exception:
logger.exception(
"Progress callback failed on chunk %d/%d", sent, total
)
[docs]
async def print_bitmap(
self,
bitmap: bytes,
height: int,
density: Density | None = Density.MEDIUM,
mode: ScaleMode = ScaleMode.NORMAL,
feed_lines: int = 20,
on_progress: ProgressCallback | None = None,
wait_for_completion: bool = True,
completion_timeout: float = 30.0,
abort: asyncio.Event | None = None,
short_sheet_fraction: float = 0.25,
) -> None:
"""Send pre-rendered bitmap data to the printer as one continuous stream.
Args:
bitmap: Packed 1-bit bitmap (``width_bytes * height`` bytes).
height: Number of rows in the bitmap.
density: Print concentration (None to skip).
mode: GS v 0 scaling mode.
feed_lines: Paper feed lines after printing.
on_progress: Progress callback ``(chunks_sent, total_chunks)``
per connection-sized chunk.
wait_for_completion: If True, block until the motor-stop signal
is received and confirm the printer still answers.
completion_timeout: Maximum seconds to wait for completion.
abort: Set it to stop the transfer; the band in progress is
completed with white rows so the printer stays in sync.
short_sheet_fraction: A paper-absent event while more than this
fraction of rows is still unsent means the sheet is shorter
than the page.
Raises:
RuntimeError: If not connected.
TimeoutError: If completion signal not received within timeout.
PrintAborted: If ``abort`` was set; ``reason`` is ``"cancelled"``.
SheetRanOut: If the sheet ran out early (paper-absent while most
of the page is unsent, or the printer muted after the motor
stop). ``PrintAborted("lid opened")`` if the lid was open
when the motor stopped: the printer discards data in that
state.
"""
if not self.is_connected:
raise RuntimeError("Not connected")
logger.debug(
"print_bitmap: %d rows, %d bitmap bytes, density=%s",
height,
len(bitmap),
density,
)
max_rows = self._profile.max_rows
if max_rows is not None and height > max_rows:
logger.warning(
"Raster is %d rows but the sheet holds %d; the paper will run "
"out before the job ends",
height,
max_rows,
)
stream, bands = self._build_raster_stream(
bitmap, height, density, mode, feed_lines
)
absent = asyncio.Event()
lid_open = asyncio.Event()
def watch_paper(event: DeviceEvent) -> None:
if isinstance(event, PaperEvent) and event.paper == PaperState.ABSENT:
absent.set()
if isinstance(event, LidEvent) and event.lid == LidState.OPEN:
lid_open.set()
# Armed before the transfer: a printer that abandons the job (sheet
# ran out, lid opened) stops the motor while data is still in flight.
loop = asyncio.get_running_loop()
stopped: asyncio.Future[DeviceEvent] = loop.create_future()
entry = (MotorStopEvent, stopped)
self._waiters.append(entry)
self._event_callbacks.append(watch_paper)
self._rows_sent = 0
try:
await self._transfer(
stream,
bands,
height,
abort,
absent,
lid_open,
short_sheet_fraction,
on_progress,
)
if not wait_for_completion:
return
logger.debug("print_bitmap: waiting for motor stop")
try:
await asyncio.wait_for(stopped, timeout=completion_timeout)
except TimeoutError:
raise TimeoutError(
"Timed out waiting for print completion signal"
) from None
lid = await self.query_lid(timeout=1.0)
if lid is None:
raise SheetRanOut("paper ran out before the page ended")
if lid == LidState.OPEN:
raise PrintAborted("lid opened")
finally:
self._waiters.remove(entry)
self._event_callbacks.remove(watch_paper)
self._rows_sent = 0
logger.debug("print_bitmap: complete")
async def _wait_for_motor_stop(self, timeout: float) -> None:
"""Wait for the print-complete (motor stop) signal.
Args:
timeout: Maximum seconds to wait.
Raises:
TimeoutError: If the signal is not received in time.
"""
if await self._wait_for(MotorStopEvent, timeout) is None:
raise TimeoutError("Timed out waiting for print completion signal")
# ------------------------------------------------------------------
# Image pipeline
# ------------------------------------------------------------------
[docs]
async def print_image(
self,
source: str | Path | Image.Image,
density: Density | None = Density.MEDIUM,
fit: ImageFit = ImageFit.FIT_WIDTH,
dither: DitherMode = DitherMode.FLOYD_STEINBERG,
feed_lines: int = 20,
on_progress: ProgressCallback | None = None,
wait_for_completion: bool = True,
completion_timeout: float = 30.0,
) -> None:
"""Full print pipeline: load image, process, print, feed.
Accepts a file path or an already-opened PIL Image. Handles the
complete pipeline from image to paper.
Args:
source: Path to an image file, or a PIL ``Image`` instance.
density: Print concentration (None to skip).
fit: How to fit the image to the print area. ``FIT_PAGE`` bounds
the result by the profile's ``max_rows`` as well as its width.
dither: Dithering algorithm for 1-bit conversion.
feed_lines: Paper feed lines after printing.
on_progress: Progress callback ``(chunks_sent, total_chunks)``.
wait_for_completion: If True, block until print-complete signal.
completion_timeout: Maximum seconds to wait for completion.
Raises:
RuntimeError: If not connected.
FileNotFoundError: If the source path doesn't exist.
ValueError: If ``fit`` is ``FIT_PAGE`` and the profile has no
``max_rows``.
"""
logger.debug("print_image: source=%r fit=%s dither=%s", source, fit, dither)
def _process_image() -> tuple[bytes, int]:
"""Load, resize, dither, and pack the image (CPU-bound)."""
img = Image.open(source) if isinstance(source, (str, Path)) else source
processed = prepare_image(
img,
target_width=self._profile.print_width_px,
fit=fit,
dither=dither,
max_height=self._profile.max_rows,
)
return image_to_bitmap(processed), processed.size[1]
bitmap, height = await asyncio.to_thread(_process_image)
logger.debug("print_image: processed to %d rows", height)
await self.print_bitmap(
bitmap,
height,
density=density,
feed_lines=feed_lines,
on_progress=on_progress,
wait_for_completion=wait_for_completion,
completion_timeout=completion_timeout,
)
# ------------------------------------------------------------------
# Async context manager
# ------------------------------------------------------------------
async def __aenter__(self) -> Self:
"""Enter the async context manager.
Returns:
This ``Printer`` instance. Call ``connect()`` to establish
the BLE connection.
"""
return self
async def __aexit__(self, *_exc: object) -> None:
"""Exit the async context manager and disconnect."""
await self.disconnect()