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.
@@ -0,0 +1,310 @@
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
+ """Client for the bridge socket, used by the CLI and by printer-application tests."""
18
+
19
+ import asyncio
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any, Self
23
+
24
+ from PIL import Image
25
+
26
+ from phomemo.bridge.protocol import encode_message, read_message
27
+
28
+ MEDIA = {
29
+ "a4": (297.0, False),
30
+ "a5": (148.0, True),
31
+ "roll": (None, False),
32
+ }
33
+ QUALITY = {"draft": "low", "normal": "medium", "high": "high"}
34
+ _ROWS_PER_MESSAGE = 64
35
+
36
+
37
+ class BridgeError(RuntimeError):
38
+ """The bridge refused or failed a command; the message is its ``error``."""
39
+
40
+
41
+ class BridgeClient:
42
+ """One connection to the bridge.
43
+
44
+ Args:
45
+ socket_path: The bridge's unix socket.
46
+ on_event: Called with each pushed event while a reply is pending.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ socket_path: Path,
52
+ on_event: Callable[[dict[str, Any]], None] | None = None,
53
+ ) -> None:
54
+ self._path = socket_path
55
+ self._on_event = on_event
56
+ self._reader: asyncio.StreamReader | None = None
57
+ self._writer: asyncio.StreamWriter | None = None
58
+ self._pending: str | None = None
59
+
60
+ async def __aenter__(self) -> Self:
61
+ await self.connect()
62
+ return self
63
+
64
+ async def __aexit__(self, *_exc: object) -> None:
65
+ await self.disconnect()
66
+
67
+ async def connect(self) -> None:
68
+ """Open the socket connection."""
69
+ self._reader, self._writer = await asyncio.open_unix_connection(str(self._path))
70
+
71
+ async def disconnect(self) -> None:
72
+ """Close the socket connection."""
73
+ if self._writer is not None:
74
+ self._writer.close()
75
+ await self._writer.wait_closed()
76
+ self._writer = None
77
+ self._reader = None
78
+
79
+ async def send(self, obj: dict[str, Any], payload: bytes = b"") -> None:
80
+ """Send a command without waiting for its reply."""
81
+ assert self._writer is not None, "not connected"
82
+ if obj.get("cmd") != "abort":
83
+ self._pending = obj.get("cmd")
84
+ self._writer.write(encode_message(obj, payload))
85
+ await self._writer.drain()
86
+
87
+ async def reply(self) -> dict[str, Any]:
88
+ """Read the reply to the pending command, dispatching events on the way.
89
+
90
+ The bridge acknowledges an ``abort`` sent while another command is
91
+ pending before it answers that command; the acknowledgement is
92
+ skipped here.
93
+
94
+ Raises:
95
+ BridgeError: If the reply reports ``ok: false``.
96
+ """
97
+ assert self._reader is not None, "not connected"
98
+ while True:
99
+ obj, _ = await read_message(self._reader)
100
+ if "event" in obj:
101
+ if self._on_event is not None:
102
+ self._on_event(obj)
103
+ continue
104
+ if obj.get("cmd") == "abort" and self._pending != "abort":
105
+ continue
106
+ if not obj.get("ok"):
107
+ raise BridgeError(obj.get("error", "unknown error"))
108
+ return obj
109
+
110
+ async def call(self, obj: dict[str, Any], payload: bytes = b"") -> dict[str, Any]:
111
+ """Send a command and return its reply."""
112
+ await self.send(obj, payload)
113
+ return await self.reply()
114
+
115
+ async def discover(self, timeout: float = 5.0) -> list[dict[str, str]]:
116
+ return (await self.call({"cmd": "discover", "timeout": timeout}))["printers"]
117
+
118
+ async def open(self, address: str | None = None) -> dict[str, Any]:
119
+ """Bring the printer connection up.
120
+
121
+ Args:
122
+ address: Printer to connect to; ``None`` keeps the bridge's choice
123
+ (configured address, the last printer, or a name scan).
124
+
125
+ Returns:
126
+ ``connected`` and, when that is false, the ``reason``.
127
+ """
128
+ obj: dict[str, Any] = {"cmd": "open"}
129
+ if address is not None:
130
+ obj["address"] = address
131
+ reply = await self.call(obj)
132
+ return {k: v for k, v in reply.items() if k not in ("ok", "cmd")}
133
+
134
+ async def drop_link(self) -> None:
135
+ """Drop the printer connection now instead of after the idle timeout."""
136
+ await self.call({"cmd": "disconnect"})
137
+
138
+ async def info(self) -> dict[str, Any]:
139
+ """Device details and sensors of the linked printer."""
140
+ reply = await self.call({"cmd": "info"})
141
+ return {k: v for k, v in reply.items() if k not in ("ok", "cmd")}
142
+
143
+ async def auto_off(self, minutes: int | None = None) -> int | None:
144
+ """Read the linked printer's auto-off timer, setting it first if given.
145
+
146
+ Args:
147
+ minutes: New timeout, a multiple of 5; 0 = never switch off.
148
+
149
+ Returns:
150
+ The timer in minutes as the printer reports it.
151
+ """
152
+ obj: dict[str, Any] = {"cmd": "auto_off"}
153
+ if minutes is not None:
154
+ obj["minutes"] = minutes
155
+ return (await self.call(obj))["auto_off_minutes"]
156
+
157
+ async def close(self) -> None:
158
+ await self.call({"cmd": "close"})
159
+
160
+ async def status(self, address: str | None = None) -> dict[str, Any]:
161
+ """Connection state and sensors; ``last_seen`` values without a connection.
162
+
163
+ Args:
164
+ address: Printer to report on; ``None`` means the linked or
165
+ last linked one.
166
+ """
167
+ obj: dict[str, Any] = {"cmd": "status"}
168
+ if address is not None:
169
+ obj["address"] = address
170
+ reply = await self.call(obj)
171
+ return {k: v for k, v in reply.items() if k not in ("ok", "cmd")}
172
+
173
+ async def wait_ready(self, settle: float | None = None) -> None:
174
+ obj: dict[str, Any] = {"cmd": "wait_ready"}
175
+ if settle is not None:
176
+ obj["settle"] = settle
177
+ await self.call(obj)
178
+
179
+ async def page_begin(
180
+ self,
181
+ width: int,
182
+ length_rows: int,
183
+ bits: int = 8,
184
+ tone: str = "auto",
185
+ content: str = "auto",
186
+ density: str = "medium",
187
+ media_length_mm: float | None = 297.0,
188
+ rotate: bool = False,
189
+ ) -> None:
190
+ await self.call(
191
+ {
192
+ "cmd": "page_begin",
193
+ "width": width,
194
+ "length_rows": length_rows,
195
+ "bits": bits,
196
+ "tone": tone,
197
+ "content": content,
198
+ "density": density,
199
+ "media_length_mm": media_length_mm,
200
+ "rotate": rotate,
201
+ }
202
+ )
203
+
204
+ async def lines(self, count: int, payload: bytes) -> None:
205
+ await self.call({"cmd": "lines", "count": count}, payload)
206
+
207
+ async def page_end(self) -> int:
208
+ return int((await self.call({"cmd": "page_end"}))["impressions"])
209
+
210
+ async def eject(self) -> bool:
211
+ return bool((await self.call({"cmd": "eject"}))["ejected"])
212
+
213
+ async def identify(self) -> None:
214
+ """Eject a loaded sheet, then pulse the feed three times."""
215
+ await self.call({"cmd": "identify"})
216
+
217
+ async def test_page(self, versions: dict[str, str] | None = None) -> Path:
218
+ """Render the test page and return the PNG's path.
219
+
220
+ Args:
221
+ versions: Component versions to print, by name.
222
+ """
223
+ reply = await self.call(
224
+ {
225
+ "cmd": "test_page",
226
+ "printer_name": "Phomemo M08F",
227
+ "versions": versions or {},
228
+ }
229
+ )
230
+ return Path(reply["path"])
231
+
232
+ async def print_test_page(self, quality: str = "normal") -> int:
233
+ """Render the test page and print it through the bridge.
234
+
235
+ The page covers the area below the sheet's unprintable lead, so it
236
+ gets that lead as white rows on top before the normal page
237
+ preparation drops them again.
238
+
239
+ Args:
240
+ quality: ``draft``, ``normal`` or ``high``.
241
+
242
+ Returns:
243
+ Impressions printed (1).
244
+ """
245
+ path = await self.test_page()
246
+ page = Image.open(path).convert("L")
247
+ lead = round(10 / 25.4 * 203)
248
+ canvas = Image.new("L", (page.width, page.height + lead), 255)
249
+ canvas.paste(page, (0, lead))
250
+ padded = path.with_name("phomemo-testpage-sheet.png")
251
+ canvas.save(padded)
252
+ return await self.print_image(padded, media="a4", quality=quality)
253
+
254
+ async def abort(self) -> None:
255
+ await self.call({"cmd": "abort"})
256
+
257
+ async def print_image(
258
+ self,
259
+ path: Path,
260
+ media: str = "a4",
261
+ tone: str = "auto",
262
+ quality: str = "normal",
263
+ content: str = "auto",
264
+ abort_after: float | None = None,
265
+ ) -> int:
266
+ """Print an image file as one page: open, wait for a sheet, stream, end.
267
+
268
+ Args:
269
+ path: Image file readable by Pillow.
270
+ media: ``a4``, ``a5`` (fed long edge first, page rotated) or ``roll``.
271
+ tone: ``auto`` or a ``ToneMode`` value.
272
+ quality: ``draft``, ``normal`` or ``high`` (maps to density).
273
+ content: ``auto``, ``text``, ``graphic`` or ``photo``.
274
+ abort_after: Seconds after ``page_end`` to send ``abort``; for testing.
275
+
276
+ Returns:
277
+ Impressions printed (1).
278
+
279
+ Raises:
280
+ BridgeError: If any step fails.
281
+ KeyError: If ``media`` or ``quality`` is not known.
282
+ """
283
+ length_mm, rotate = MEDIA[media]
284
+ density = QUALITY[quality]
285
+ img = Image.open(path).convert("L")
286
+ await self.open()
287
+ await self.wait_ready()
288
+ await self.page_begin(
289
+ img.width,
290
+ img.height,
291
+ bits=8,
292
+ tone=tone,
293
+ content=content,
294
+ density=density,
295
+ media_length_mm=length_mm,
296
+ rotate=rotate,
297
+ )
298
+ raw = img.tobytes()
299
+ for start in range(0, img.height, _ROWS_PER_MESSAGE):
300
+ count = min(_ROWS_PER_MESSAGE, img.height - start)
301
+ await self.lines(
302
+ count, raw[start * img.width : (start + count) * img.width]
303
+ )
304
+ await self.send({"cmd": "page_end"})
305
+ if abort_after is not None:
306
+ await asyncio.sleep(abort_after)
307
+ await self.send({"cmd": "abort"})
308
+ reply = await self.reply()
309
+ await self.close()
310
+ return int(reply["impressions"])
@@ -0,0 +1,184 @@
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
+ """Bridge configuration: TOML files layered under environment overrides."""
18
+
19
+ import logging
20
+ import os
21
+ import tomllib
22
+ from collections.abc import Mapping
23
+ from dataclasses import fields
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from phomemo.bridge.server import BridgeConfig
28
+ from phomemo.printer import AUTO_OFF_MAX_MINUTES
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ SYSTEM_CONFIG = Path("/etc/phomemo/bridge.toml")
33
+ SYSTEM_SOCKET = Path("/run/phomemo/bridge.sock")
34
+ SOCKET_NAME = "phomemo-bridge.sock"
35
+ SOCKET_ENV = "PHOMEMO_BRIDGE_SOCKET"
36
+
37
+ _NUMERIC = {
38
+ "idle_disconnect_minutes",
39
+ "settle_seconds",
40
+ "completion_timeout",
41
+ "drain_seconds",
42
+ }
43
+ _STRING = {"socket_path"}
44
+
45
+
46
+ class _Invalid(ValueError):
47
+ """A configuration value that cannot be used; the message says why."""
48
+
49
+
50
+ class ConfigError(ValueError):
51
+ """A configuration file named explicitly does not exist."""
52
+
53
+
54
+ def default_paths(env: Mapping[str, str]) -> list[Path]:
55
+ """System file first, then the user's XDG config file.
56
+
57
+ Args:
58
+ env: Environment to read ``XDG_CONFIG_HOME`` and ``HOME`` from.
59
+
60
+ Returns:
61
+ Candidate paths in override order; missing files are skipped later.
62
+ """
63
+ xdg = env.get("XDG_CONFIG_HOME") or os.path.join(env.get("HOME", "~"), ".config")
64
+ return [SYSTEM_CONFIG, Path(xdg).expanduser() / "phomemo" / "bridge.toml"]
65
+
66
+
67
+ def default_socket(env: Mapping[str, str], system_socket: Path | None = None) -> Path:
68
+ """The system bridge's socket when it exists, else one under ``XDG_RUNTIME_DIR``.
69
+
70
+ The system service listens at ``/run/phomemo/bridge.sock``; a user's
71
+ command line talks to it when it is there. Without it (development, a
72
+ user service) the socket lives in the user's runtime directory.
73
+
74
+ Args:
75
+ env: Environment providing ``XDG_RUNTIME_DIR``.
76
+ system_socket: The system bridge's socket; ``SYSTEM_SOCKET`` by default.
77
+
78
+ Returns:
79
+ The socket path to listen on or connect to.
80
+ """
81
+ system_socket = system_socket or SYSTEM_SOCKET
82
+ if system_socket.exists():
83
+ return system_socket
84
+ runtime = env.get("XDG_RUNTIME_DIR")
85
+ return Path(runtime) / SOCKET_NAME if runtime else system_socket
86
+
87
+
88
+ def _auto_off(value: Any) -> tuple[int, str | None]:
89
+ """The timer for a configured value, and a note when it had to be adjusted."""
90
+ if isinstance(value, bool) or not isinstance(value, int | float) or value < 0:
91
+ raise _Invalid(
92
+ f"expected minutes as a number between 0 and {AUTO_OFF_MAX_MINUTES}, "
93
+ f"got {value!r}"
94
+ )
95
+ if value == 0:
96
+ minutes = 0
97
+ else:
98
+ minutes = min(max(5, int(value / 5 + 0.5) * 5), AUTO_OFF_MAX_MINUTES)
99
+ if minutes == value and isinstance(value, int):
100
+ return minutes, None
101
+ return minutes, f"{value!r} is not a valid timer (5-minute steps), using {minutes}"
102
+
103
+
104
+ def _check(key: str, value: Any) -> tuple[Any, str | None]:
105
+ """The usable value for ``key``, and a note when it was adjusted.
106
+
107
+ Raises:
108
+ _Invalid: If the value cannot be used.
109
+ """
110
+ if key == "auto_off_minutes":
111
+ return _auto_off(value)
112
+ if key in _NUMERIC:
113
+ if isinstance(value, bool) or not isinstance(value, int | float):
114
+ raise _Invalid(f"expected a number, got {value!r}")
115
+ if value < 0:
116
+ raise _Invalid(f"must not be negative, got {value!r}")
117
+ return float(value), None
118
+ if key in _STRING:
119
+ if not isinstance(value, str):
120
+ raise _Invalid(f"expected a string, got {value!r}")
121
+ return value, None
122
+ raise _Invalid("unknown key")
123
+
124
+
125
+ def _read(path: Path) -> dict[str, Any]:
126
+ """The usable settings of one file; every problem is logged, not raised."""
127
+ try:
128
+ data = tomllib.loads(path.read_text())
129
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
130
+ logger.warning("%s: cannot read the file (%s); ignored", path, exc)
131
+ return {}
132
+ values: dict[str, Any] = {}
133
+ for key, value in data.items():
134
+ try:
135
+ values[key], note = _check(key, value)
136
+ except _Invalid as exc:
137
+ logger.warning("%s: %s: %s; ignored", path, key, exc)
138
+ continue
139
+ if note is not None:
140
+ logger.warning("%s: %s: %s", path, key, note)
141
+ return values
142
+
143
+
144
+ def load_config(
145
+ paths: list[Path] | None = None,
146
+ env: Mapping[str, str] = os.environ,
147
+ require_files: bool = False,
148
+ ) -> BridgeConfig:
149
+ """Build the bridge configuration.
150
+
151
+ Later files override earlier ones; ``PHOMEMO_BRIDGE_SOCKET`` overrides
152
+ the socket path from any file.
153
+
154
+ Args:
155
+ paths: Files to read in override order; ``None`` means the system
156
+ file then the user's XDG file.
157
+ env: Environment for the socket override and default locations.
158
+ require_files: Treat a missing file in ``paths`` as an error (for
159
+ a path the user named explicitly).
160
+
161
+ Returns:
162
+ The merged configuration.
163
+
164
+ Problems inside a file (unknown keys, wrong types, out-of-range values,
165
+ a file that does not parse) are logged as warnings and the affected
166
+ setting keeps its default.
167
+
168
+ Raises:
169
+ ConfigError: If a file in ``paths`` is missing and ``require_files``
170
+ is set.
171
+ """
172
+ merged: dict[str, Any] = {}
173
+ for path in default_paths(env) if paths is None else paths:
174
+ if path.is_file():
175
+ merged.update(_read(path))
176
+ elif require_files:
177
+ raise ConfigError(f"{path}: no such file")
178
+ if SOCKET_ENV in env:
179
+ merged["socket_path"] = env[SOCKET_ENV]
180
+ socket_path = Path(merged.pop("socket_path", default_socket(env)))
181
+ known = {f.name for f in fields(BridgeConfig)}
182
+ return BridgeConfig(
183
+ socket_path=socket_path, **{k: v for k, v in merged.items() if k in known}
184
+ )
@@ -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
+ """Wire format of the bridge socket.
18
+
19
+ Every message is one JSON object on its own line. A message that carries
20
+ raw bytes (raster rows) states their length in ``"bytes"`` and the bytes
21
+ follow the newline immediately. Replies and pushed events use the same
22
+ framing in the other direction.
23
+ """
24
+
25
+ import asyncio
26
+ import json
27
+ from typing import Any
28
+
29
+ MAX_LINE = 64 * 1024
30
+ MAX_PAYLOAD = 16 * 1024 * 1024
31
+
32
+
33
+ class ProtocolError(ValueError):
34
+ """A message violated the wire format."""
35
+
36
+
37
+ def encode_message(obj: dict[str, Any], payload: bytes = b"") -> bytes:
38
+ """Serialise a message, attaching ``payload`` and its ``bytes`` count.
39
+
40
+ Args:
41
+ obj: The message fields.
42
+ payload: Raw bytes to send after the line.
43
+
44
+ Returns:
45
+ Bytes ready to write to the socket.
46
+ """
47
+ if payload:
48
+ obj = {**obj, "bytes": len(payload)}
49
+ return json.dumps(obj).encode() + b"\n" + payload
50
+
51
+
52
+ async def read_message(reader: asyncio.StreamReader) -> tuple[dict[str, Any], bytes]:
53
+ """Read one message and its payload, if any.
54
+
55
+ Args:
56
+ reader: Stream to read from.
57
+
58
+ Returns:
59
+ The message object and its raw payload (empty when none).
60
+
61
+ Raises:
62
+ ProtocolError: On malformed JSON, a non-object message, an
63
+ over-long line, or an out-of-range payload length.
64
+ asyncio.IncompleteReadError: If the stream ends mid-message.
65
+ """
66
+ try:
67
+ line = await reader.readuntil(b"\n")
68
+ except asyncio.LimitOverrunError as exc:
69
+ raise ProtocolError(f"line exceeds {MAX_LINE} bytes") from exc
70
+ if len(line) > MAX_LINE:
71
+ raise ProtocolError(f"line exceeds {MAX_LINE} bytes")
72
+ try:
73
+ obj = json.loads(line)
74
+ except ValueError as exc:
75
+ raise ProtocolError(f"invalid JSON: {exc}") from exc
76
+ if not isinstance(obj, dict):
77
+ raise ProtocolError("message must be a JSON object")
78
+ size = obj.get("bytes", 0)
79
+ if not isinstance(size, int) or size < 0 or size > MAX_PAYLOAD:
80
+ raise ProtocolError(f"invalid payload length {size!r}")
81
+ payload = await reader.readexactly(size) if size else b""
82
+ return obj, payload