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 +82 -0
- phomemo/bridge/__init__.py +23 -0
- phomemo/bridge/__main__.py +315 -0
- phomemo/bridge/client.py +310 -0
- phomemo/bridge/config.py +184 -0
- phomemo/bridge/protocol.py +82 -0
- phomemo/bridge/server.py +307 -0
- phomemo/bridge/session.py +741 -0
- phomemo/bridge/testpage.py +232 -0
- phomemo/discovery.py +72 -0
- phomemo/events.py +288 -0
- phomemo/imaging.py +278 -0
- phomemo/models/README.md +26 -0
- phomemo/models/m08f/icon-128.png +0 -0
- phomemo/models/m08f/icon-48.png +0 -0
- phomemo/models/m08f/icon-512.png +0 -0
- phomemo/models/m08f/model.json +30 -0
- phomemo/printer.py +1050 -0
- phomemo/profiles/__init__.py +60 -0
- phomemo/protocol.py +228 -0
- phomemo/py.typed +0 -0
- phomemo/registry.py +378 -0
- phomemo/transport.py +251 -0
- phomemo-0.3.0.dist-info/METADATA +82 -0
- phomemo-0.3.0.dist-info/RECORD +29 -0
- phomemo-0.3.0.dist-info/WHEEL +4 -0
- phomemo-0.3.0.dist-info/entry_points.txt +2 -0
- phomemo-0.3.0.dist-info/licenses/COPYING +674 -0
- phomemo-0.3.0.dist-info/licenses/COPYING.LESSER +165 -0
phomemo/bridge/client.py
ADDED
|
@@ -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"])
|
phomemo/bridge/config.py
ADDED
|
@@ -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
|