phomemo 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phomemo/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ # This file is part of phomemo.
2
+ # Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
3
+ #
4
+ # phomemo is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # phomemo is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with phomemo. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Async BLE driver library for Phomemo thermal printers.
18
+
19
+ Provides a modular, profile-based API for communicating with Phomemo
20
+ thermal printers over Bluetooth Low Energy.
21
+
22
+ Basic usage::
23
+
24
+ from phomemo import Printer
25
+
26
+ async with Printer("m08f", "AA:BB:CC:DD:EE:FF") as printer:
27
+ # connect() must be called after entering the context
28
+ await printer.connect()
29
+ await printer.wait_for_ready()
30
+ await printer.print_image("photo.png")
31
+ await printer.eject_paper()
32
+ """
33
+
34
+ from phomemo import profiles # noqa F401 # auto-load profiles at import time
35
+ from phomemo.discovery import discover
36
+ from phomemo.events import (
37
+ BatteryEvent,
38
+ DeviceEvent,
39
+ EventKind,
40
+ FirmwareEvent,
41
+ LidEvent,
42
+ LidState,
43
+ MotorStopEvent,
44
+ PaperEvent,
45
+ PaperState,
46
+ SerialEvent,
47
+ TimerEvent,
48
+ )
49
+ from phomemo.imaging import DitherMode, ImageFit, image_to_bitmap, prepare_image
50
+ from phomemo.printer import Printer
51
+ from phomemo.protocol import Density
52
+ from phomemo.registry import (
53
+ PrinterProfile,
54
+ get_profile,
55
+ list_profiles,
56
+ register_profile,
57
+ )
58
+
59
+ __all__ = [
60
+ "BatteryEvent",
61
+ "Density",
62
+ "DeviceEvent",
63
+ "DitherMode",
64
+ "EventKind",
65
+ "FirmwareEvent",
66
+ "ImageFit",
67
+ "LidEvent",
68
+ "LidState",
69
+ "MotorStopEvent",
70
+ "PaperEvent",
71
+ "PaperState",
72
+ "Printer",
73
+ "PrinterProfile",
74
+ "SerialEvent",
75
+ "TimerEvent",
76
+ "discover",
77
+ "get_profile",
78
+ "image_to_bitmap",
79
+ "list_profiles",
80
+ "prepare_image",
81
+ "register_profile",
82
+ ]
@@ -0,0 +1,23 @@
1
+ # This file is part of phomemo.
2
+ # Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
3
+ #
4
+ # phomemo is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # phomemo is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with phomemo. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """The bridge: drives a printer over a unix socket for the printer app.
18
+
19
+ The bridge owns the device side (Bluetooth connection, raster encoding, tone
20
+ conversion, sheet handling); a printer app owns the IPP side and
21
+ talks to the bridge with a few JSON commands. See ``protocol`` for the wire
22
+ format and ``server`` for the command set.
23
+ """
@@ -0,0 +1,315 @@
1
+ # This file is part of phomemo.
2
+ # Copyright (C) 2026 Paul Bütof, Passionate Bytes Solutions
3
+ #
4
+ # phomemo is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Lesser General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # phomemo is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public License
15
+ # along with phomemo. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """``phomemo-bridge`` command line: run the bridge or drive it directly.
18
+
19
+ Sub-command names follow the printer app's where both do the same
20
+ thing (``server``, ``devices``, ``status``).
21
+ """
22
+
23
+ import argparse
24
+ import asyncio
25
+ import json
26
+ import logging
27
+ import sys
28
+ import textwrap
29
+ from importlib.metadata import version
30
+ from pathlib import Path
31
+
32
+ from phomemo.bridge.client import MEDIA, QUALITY, BridgeClient, BridgeError
33
+ from phomemo.bridge.config import ConfigError, load_config
34
+ from phomemo.bridge.server import serve
35
+ from phomemo.imaging import ToneMode
36
+
37
+ _PRINTER_CHOICE = (
38
+ "Which printer: the one given with --address, else the printer connectioned or "
39
+ "used last since the bridge started, else the only printer of a known model "
40
+ "a scan finds. With several printers in range and none of these, the "
41
+ 'command refuses and lists them; pick one with --address (see "devices"). '
42
+ "The model comes from the printer's advertised name."
43
+ )
44
+
45
+ _COMMANDS: dict[str, tuple[str, str]] = {
46
+ "server": (
47
+ "run the bridge service",
48
+ "Run the bridge in the foreground: listen on the socket, own the "
49
+ "Bluetooth connection to the printer and serve the printer app and "
50
+ "this command line. The packaged system service runs this.",
51
+ ),
52
+ "devices": (
53
+ "list printers in range",
54
+ "Scan for printers of every known model and list each one's Bluetooth "
55
+ "address, model and advertised name. A printer shows up only while it "
56
+ "is switched on and not linked to another host.",
57
+ ),
58
+ "connect": (
59
+ "connect to the printer",
60
+ "Connect to the printer over Bluetooth. The bridge keeps the connection until "
61
+ "the printer switches off or leaves range, or until "
62
+ "idle_disconnect_minutes pass without a command (see the "
63
+ "configuration), so later commands and jobs skip the reconnect. At each "
64
+ "new connection the bridge sets the printer's "
65
+ "auto-off timer to auto_off_minutes when that key is set.\n\n"
66
+ + _PRINTER_CHOICE,
67
+ ),
68
+ "disconnect": (
69
+ "drop the connection now",
70
+ "Drop the Bluetooth connection right away instead of after the idle time, so "
71
+ "another host (a phone, another computer) can reach the printer.",
72
+ ),
73
+ "status": (
74
+ "show connection, sensors and device details",
75
+ "Show whether the bridge holds a connection and, when it does, the connected "
76
+ "printer's address, lid and paper sensors, battery, firmware, serial "
77
+ "number and auto-off timer. Without a connection, shows the values last read "
78
+ 'from that printer under "last_seen", with the time they were read. Does '
79
+ 'not connect by itself; run "connect" first. With --address, reports on '
80
+ "that printer.",
81
+ ),
82
+ "auto-off": (
83
+ "show or set the auto-off timer",
84
+ "Show the linked printer's auto-off timer, or set it first when MINUTES "
85
+ "is given (a multiple of 5 up to 1275; 0 = never switch off). When "
86
+ "auto_off_minutes is set in the configuration, the bridge writes that "
87
+ "value again at the next connection, so change the key for a lasting "
88
+ 'setting. Needs a connection; run "connect" first.',
89
+ ),
90
+ "print": (
91
+ "print one image file as a page",
92
+ "Print an image file (PNG, JPEG, anything Pillow reads) as one page, "
93
+ "scaled to the media width, then eject the sheet.\n\n" + _PRINTER_CHOICE,
94
+ ),
95
+ "eject": (
96
+ "feed the sheet out",
97
+ "Feed the loaded sheet out of the printer.\n\n" + _PRINTER_CHOICE,
98
+ ),
99
+ "identify": (
100
+ "make the printer show itself",
101
+ "Eject a loaded sheet, then pulse the paper feed three times so you can "
102
+ "tell which printer this is.\n\n" + _PRINTER_CHOICE,
103
+ ),
104
+ "testpage": (
105
+ "print the test page",
106
+ "Print the test page: device details, millimetre scales, a gray ramp, "
107
+ "line widths and text samples.\n\n" + _PRINTER_CHOICE,
108
+ ),
109
+ }
110
+
111
+
112
+ def _parser() -> argparse.ArgumentParser:
113
+ ap = argparse.ArgumentParser(prog="phomemo-bridge", description=__doc__)
114
+ ap.add_argument(
115
+ "--config", type=Path, help="read this TOML file instead of the defaults"
116
+ )
117
+ ap.add_argument("-v", "--verbose", action="store_true", help="debug logging")
118
+ ap.add_argument(
119
+ "-V",
120
+ "--version",
121
+ action="version",
122
+ version=f"%(prog)s {version('phomemo')}",
123
+ help="print the version and exit",
124
+ )
125
+ ap.set_defaults(address=None)
126
+ sub = ap.add_subparsers(dest="command", required=True, metavar="COMMAND")
127
+
128
+ # Shared by every command that works on one printer.
129
+ printer_choice = argparse.ArgumentParser(add_help=False)
130
+ printer_choice.add_argument(
131
+ "-a",
132
+ "--address",
133
+ help="Bluetooth address of the printer to use (AA:BB:CC:DD:EE:FF)",
134
+ )
135
+
136
+ def add(name: str, targets_printer: bool = True) -> argparse.ArgumentParser:
137
+ summary, description = _COMMANDS[name]
138
+ paragraphs = description.split("\n\n")
139
+ return sub.add_parser(
140
+ name,
141
+ parents=[printer_choice] if targets_printer else [],
142
+ help=summary,
143
+ description="\n\n".join(textwrap.fill(p, 79) for p in paragraphs),
144
+ formatter_class=argparse.RawDescriptionHelpFormatter,
145
+ )
146
+
147
+ add("server", targets_printer=False)
148
+ devices = add("devices", targets_printer=False)
149
+ devices.add_argument(
150
+ "--timeout", type=float, default=5.0, help="scan seconds (default: 5)"
151
+ )
152
+ add("connect")
153
+ add("disconnect", targets_printer=False)
154
+ add("status")
155
+ auto_off = add("auto-off")
156
+ auto_off.add_argument(
157
+ "minutes", type=int, nargs="?", help="new timeout in minutes, 0 = never"
158
+ )
159
+ pr = add("print")
160
+ pr.add_argument("file", type=Path, help="image file to print")
161
+ pr.add_argument(
162
+ "--media", choices=sorted(MEDIA), default="a4", help="sheet size (default: a4)"
163
+ )
164
+ pr.add_argument(
165
+ "--tone",
166
+ choices=["auto", *[t.value for t in ToneMode]],
167
+ default="auto",
168
+ help="how gray becomes dots (default: auto, chosen from the content)",
169
+ )
170
+ pr.add_argument(
171
+ "--quality",
172
+ choices=sorted(QUALITY),
173
+ default="normal",
174
+ help="print density (default: normal)",
175
+ )
176
+ pr.add_argument(
177
+ "--content",
178
+ choices=["auto", "text", "graphic", "photo"],
179
+ default="auto",
180
+ help="content hint for --tone auto (default: auto)",
181
+ )
182
+ pr.add_argument(
183
+ "--abort-after",
184
+ type=float,
185
+ metavar="SECONDS",
186
+ help="cancel the page this many seconds into the transfer (testing)",
187
+ )
188
+ add("eject")
189
+ add("identify")
190
+ tp = add("testpage")
191
+ tp.add_argument(
192
+ "--quality",
193
+ choices=sorted(QUALITY),
194
+ default="normal",
195
+ help="print density (default: normal)",
196
+ )
197
+ return ap
198
+
199
+
200
+ def _fail(message: str) -> int:
201
+ print(f"error: {message}", file=sys.stderr)
202
+ return 1
203
+
204
+
205
+ async def _linked(client: BridgeClient, address: str | None) -> dict | None:
206
+ """The bridge's status when connected to ``address`` (or any printer)."""
207
+ status = await client.status(address)
208
+ return status if status["connected"] else None
209
+
210
+
211
+ async def _status(client: BridgeClient, address: str | None) -> dict:
212
+ status = await client.status(address)
213
+ if not status["connected"]:
214
+ return status
215
+ info = await client.info()
216
+ for key in ("firmware", "serial", "auto_off_minutes"):
217
+ status[key] = info[key]
218
+ return status
219
+
220
+
221
+ async def _run_client(args: argparse.Namespace, socket_path: Path) -> int:
222
+ def show(event: dict) -> None:
223
+ print(json.dumps(event), file=sys.stderr)
224
+
225
+ async with BridgeClient(socket_path, on_event=show) as client:
226
+ if args.command in ("connect", "print", "eject", "identify", "testpage"):
227
+ opened = await client.open(args.address)
228
+ if not opened["connected"] and args.command != "print":
229
+ return _fail(f"not reachable: {opened.get('reason', 'unknown')}")
230
+ if args.command == "connect":
231
+ status = await client.status()
232
+ print(f"connected to {status.get('address')}")
233
+ elif args.command == "disconnect":
234
+ await client.drop_link()
235
+ print("disconnected")
236
+ elif args.command == "status":
237
+ print(json.dumps(await _status(client, args.address), indent=2))
238
+ elif args.command == "auto-off":
239
+ status = await _linked(client, args.address)
240
+ if status is None:
241
+ return _fail('not connected; run "phomemo-bridge connect" first')
242
+ minutes = await client.auto_off(args.minutes)
243
+ shown = "never" if minutes == 0 else f"{minutes} min"
244
+ print(f"{status['address']}: auto-off {shown}")
245
+ elif args.command == "print":
246
+ impressions = await client.print_image(
247
+ args.file,
248
+ media=args.media,
249
+ tone=args.tone,
250
+ quality=args.quality,
251
+ content=args.content,
252
+ abort_after=args.abort_after,
253
+ )
254
+ print(f"printed {impressions} page(s)")
255
+ elif args.command == "devices":
256
+ printers = await client.discover(args.timeout)
257
+ for printer in printers:
258
+ print(f"{printer['address']} {printer['model']:<8} {printer['name']}")
259
+ if not printers:
260
+ print("no printer in range", file=sys.stderr)
261
+ return 1
262
+ elif args.command == "eject":
263
+ print("ejected" if await client.eject() else "sheet still present")
264
+ await client.close()
265
+ elif args.command == "identify":
266
+ await client.identify()
267
+ await client.close()
268
+ print("identified")
269
+ elif args.command == "testpage":
270
+ impressions = await client.print_test_page(quality=args.quality)
271
+ print(f"printed {impressions} page(s)")
272
+ return 0
273
+
274
+
275
+ def main(argv: list[str] | None = None) -> int:
276
+ """Entry point.
277
+
278
+ Args:
279
+ argv: Arguments without the program name; ``None`` uses ``sys.argv``.
280
+
281
+ Returns:
282
+ Process exit status.
283
+ """
284
+ args = _parser().parse_args(argv)
285
+ logging.basicConfig(
286
+ level=logging.DEBUG if args.verbose else logging.INFO,
287
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
288
+ )
289
+ try:
290
+ config = load_config(
291
+ paths=[args.config] if args.config else None,
292
+ require_files=args.config is not None,
293
+ )
294
+ except ConfigError as exc:
295
+ print(f"configuration error: {exc}", file=sys.stderr)
296
+ return 2
297
+ try:
298
+ if args.command == "server":
299
+ asyncio.run(serve(config))
300
+ return 0
301
+ return asyncio.run(_run_client(args, config.socket_path))
302
+ except KeyboardInterrupt:
303
+ return 130
304
+ except BridgeError as exc:
305
+ print(f"error: {exc}", file=sys.stderr)
306
+ return 1
307
+ except (ConnectionError, FileNotFoundError) as exc:
308
+ print(
309
+ f"cannot reach the bridge at {config.socket_path}: {exc}", file=sys.stderr
310
+ )
311
+ return 1
312
+
313
+
314
+ if __name__ == "__main__":
315
+ sys.exit(main())