wp9529-printer 0.1.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.
wp9529/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Print labels on a VSON WP9529 from the command line.
2
+
3
+ Examples:
4
+ wp9529-label --info # printer status
5
+ wp9529-label --power-off 30 # switch off after 30 idle minutes
6
+ wp9529-label --title Router --text "admin / see vault" --icon mdi:router-wireless
7
+ wp9529-label --qr "WIFI:T:WPA;S:Home;P:secret;;" --text "Guest Wi-Fi"
8
+ wp9529-label --qr https://example.com --icon mdi:link --copies 3
9
+ wp9529-label --title Spices --preview previews # render a PNG only
10
+ wp9529-label --image my_label.png # an image 96 px wide
11
+ """
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ from pathlib import Path
16
+
17
+ from PIL import Image
18
+
19
+ from .driver import DENSITY, POWER_OFF_MAX, POWER_OFF_MIN, WP9529, PrinterError
20
+ from .label import render_label
21
+
22
+
23
+ def power_off_minutes(value: str) -> int:
24
+ minutes = int(value)
25
+ if not POWER_OFF_MIN <= minutes <= POWER_OFF_MAX:
26
+ raise argparse.ArgumentTypeError(f"must be {POWER_OFF_MIN}-{POWER_OFF_MAX} minutes")
27
+ return minutes
28
+
29
+
30
+ def add_print_options(p: argparse.ArgumentParser) -> None:
31
+ """Options about the roll and the printer, shared with other label tools."""
32
+ p.add_argument("--length", type=float, default=30, help="label length in mm (default 30)")
33
+ p.add_argument("--gap", type=float, default=8.5, help="gap between labels in mm (default 8.5)")
34
+ p.add_argument("--continuous", action="store_true", help="continuous tape instead of die-cut labels")
35
+ p.add_argument("--rotate", type=int, default=90, choices=[90, 270], help="flip if text prints upside down")
36
+ p.add_argument("--density", default="normal", choices=list(DENSITY))
37
+ p.add_argument("--feed", type=int, default=0, help="extra blank lines after the label")
38
+ p.add_argument("--copies", type=int, default=1, help="copies of each label")
39
+ p.add_argument("--preview", metavar="DIR", help="save rendered labels as PNGs in DIR instead of printing")
40
+ p.add_argument("--address", help="printer BLE address (default: scan for it)")
41
+ p.add_argument("-v", "--verbose", action="store_true", help="log every Bluetooth packet")
42
+
43
+
44
+ def save_previews(jobs: list[tuple[str, Image.Image]], directory: str) -> None:
45
+ out = Path(directory)
46
+ out.mkdir(parents=True, exist_ok=True)
47
+ for name, img in jobs:
48
+ img.save(out / f"{name}.png")
49
+ print(f"saved {len(jobs)} preview(s) to {out}")
50
+
51
+
52
+ async def print_jobs(jobs: list[tuple[str, Image.Image]], args, info: bool = False,
53
+ power_off: int | None = None) -> None:
54
+ """Connect once and print each (name, image) job `args.copies` times."""
55
+ async with WP9529(args.address) as printer:
56
+ print(f"printer {printer.address}: firmware {await printer.firmware_version()}")
57
+ if power_off is not None:
58
+ stored = await printer.set_power_off_minutes(power_off)
59
+ print(f"auto power-off set to {stored} minutes")
60
+ elif info:
61
+ print(f"auto power-off after {await printer.power_off_minutes()} minutes")
62
+ for name, img in jobs:
63
+ for _ in range(args.copies):
64
+ await printer.print_image(img, density=args.density, feed_lines=args.feed,
65
+ label_mm=None if args.continuous else args.length,
66
+ gap_mm=args.gap)
67
+ print(f"printed {name}" + (f" x{args.copies}" if args.copies > 1 else ""))
68
+
69
+
70
+ async def run(args) -> None:
71
+ jobs: list[tuple[str, Image.Image]] = []
72
+ if args.image:
73
+ jobs.append((Path(args.image).stem, Image.open(args.image)))
74
+ elif args.title or args.text or args.qr or args.icon:
75
+ img = render_label(title=args.title, text=args.text, qr=args.qr, icon=args.icon,
76
+ length_mm=args.length, rotate=args.rotate)
77
+ jobs.append(("label", img))
78
+ if args.preview:
79
+ if not jobs:
80
+ raise ValueError("nothing to preview: give --title, --text, --qr, --icon or --image")
81
+ save_previews(jobs, args.preview)
82
+ return
83
+ if not jobs and not args.info and args.power_off is None:
84
+ raise ValueError("nothing to do: give --title, --text, --qr, --icon, --image, --info or --power-off")
85
+ await print_jobs(jobs, args, info=args.info, power_off=args.power_off)
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> None:
89
+ p = argparse.ArgumentParser(prog="wp9529-label", description=__doc__,
90
+ formatter_class=argparse.RawDescriptionHelpFormatter)
91
+ p.add_argument("--title", default="", help="large text")
92
+ p.add_argument("--text", default="", help="smaller text (or the only text)")
93
+ p.add_argument("--qr", help="any text to encode as a QR code")
94
+ p.add_argument("--icon", help="MDI icon, e.g. mdi:tools (https://pictogrammers.com/library/mdi/)")
95
+ p.add_argument("--image", help="print an existing image (must be 96 px wide)")
96
+ p.add_argument("--info", action="store_true", help="show the printer's firmware and auto power-off time")
97
+ p.add_argument("--power-off", type=power_off_minutes, metavar="MIN",
98
+ help=f"set the printer's auto power-off time ({POWER_OFF_MIN}-{POWER_OFF_MAX} minutes)")
99
+ add_print_options(p)
100
+ args = p.parse_args(argv)
101
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, format="%(message)s")
102
+ try:
103
+ asyncio.run(run(args))
104
+ except (PrinterError, ValueError, OSError) as e:
105
+ raise SystemExit(f"error: {e}") from None
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
wp9529/driver.py ADDED
@@ -0,0 +1,241 @@
1
+ """Driver for the VSON WP9529 Bluetooth LE label printer.
2
+
3
+ Protocol reverse engineered from the iLabel Android app (com.vson.ilabel):
4
+
5
+ * GATT service 0xFFF0. Commands are written to 0xFFF1 (write without
6
+ response); replies arrive as notifications on 0xFFF4.
7
+ * Every command and reply starts with a one-byte opcode.
8
+
9
+ 01 device info / status
10
+ <- 01 width_mm(LE16) width_dots(LE16) state err_a err_b err_c err_d ...
11
+ 04 reset / cancel the current job
12
+ 02 lines(LE16) offset(LE16) 00 01 density
13
+ job header, continuous paper (density 24/32/39)
14
+ 02 lines(LE16) offset(LE16) 00 02 density label_mm gap_mm*10 copies
15
+ job header, die-cut labels with gaps
16
+ 03 line(LE16) n data[n*width/8] raster packet, n lines from `line`
17
+ <- a4 / a5 printer accepted the header
18
+ <- a7 / a8 pause / resume the data stream
19
+ b1 MAC address
20
+ b2 firmware version (the app shows it as "V" + hex)
21
+ b4 auto power-off time
22
+ <- b4 minutes ..
23
+ b5 minutes 00 set the auto power-off time (10-180 minutes)
24
+ <- b5 minutes ..
25
+
26
+ Raster data is 1 bit per pixel, MSB first, 1 = black, one print-head
27
+ width (96 px = 12 mm at 203 dpi) per line.
28
+ """
29
+ import asyncio
30
+ import logging
31
+
32
+ from bleak import BleakClient, BleakScanner
33
+ from PIL import Image, ImageOps
34
+
35
+ log = logging.getLogger("wp9529")
36
+
37
+ WRITE_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"
38
+ NOTIFY_UUID = "0000fff4-0000-1000-8000-00805f9b34fb"
39
+ NAME_PREFIX = "9529"
40
+
41
+ DOTS_PER_MM = 8
42
+ DENSITY = {"light": 24, "normal": 32, "dark": 39}
43
+ POWER_OFF_MIN, POWER_OFF_MAX = 10, 180 # minutes, the range the iLabel app allows
44
+ LINES_PER_PACKET = 4
45
+
46
+
47
+ class PrinterError(Exception):
48
+ pass
49
+
50
+
51
+ class Info:
52
+ def __init__(self, raw: bytes):
53
+ self.raw = raw
54
+ self.width_mm = int.from_bytes(raw[1:3], "little")
55
+ self.width = int.from_bytes(raw[3:5], "little")
56
+ self.state = raw[5]
57
+ self.errors = tuple(raw[6:10])
58
+
59
+ @property
60
+ def busy(self):
61
+ return self.state != 0
62
+
63
+ @property
64
+ def has_error(self):
65
+ return any(self.errors)
66
+
67
+ def __repr__(self):
68
+ return (f"Info(width={self.width_mm} mm/{self.width} dots, state={self.state}, "
69
+ f"errors={self.errors}, raw={self.raw.hex(' ')})")
70
+
71
+
72
+ async def find_printer(timeout=8.0):
73
+ """Return the address of the first WP9529 found nearby."""
74
+ dev = await BleakScanner.find_device_by_filter(
75
+ lambda d, adv: (adv.local_name or d.name or "").startswith(NAME_PREFIX),
76
+ timeout=timeout,
77
+ )
78
+ if dev is None:
79
+ raise PrinterError("No WP9529 found. Is it on and disconnected from the phone app?")
80
+ return dev.address
81
+
82
+
83
+ def rasterize(img: Image.Image, width: int) -> list[bytes]:
84
+ """Turn an image `width` px wide into rows of packed 1-bit data (1 = black)."""
85
+ if img.width != width:
86
+ raise ValueError(f"image must be {width} px wide, got {img.width}")
87
+ bw = ImageOps.invert(img.convert("L")).point(lambda p: 255 if p >= 128 else 0, "1")
88
+ data = bw.tobytes()
89
+ stride = width // 8
90
+ return [data[i:i + stride] for i in range(0, len(data), stride)]
91
+
92
+
93
+ def build_packets(rows: list[bytes], lines_per_packet=LINES_PER_PACKET) -> list[bytes]:
94
+ packets = []
95
+ for start in range(0, len(rows), lines_per_packet):
96
+ chunk = rows[start:start + lines_per_packet]
97
+ packets.append(bytes([0x03, start & 0xFF, start >> 8, len(chunk)]) + b"".join(chunk))
98
+ return packets
99
+
100
+
101
+ class WP9529:
102
+ def __init__(self, address: str | None = None):
103
+ self.address = address
104
+ self.client: BleakClient | None = None
105
+ self.info: Info | None = None
106
+ self._replies: asyncio.Queue[bytes] = asyncio.Queue()
107
+ self._resume = asyncio.Event()
108
+ self._resume.set()
109
+
110
+ async def __aenter__(self):
111
+ await self.connect()
112
+ return self
113
+
114
+ async def __aexit__(self, *exc):
115
+ await self.disconnect()
116
+
117
+ async def connect(self):
118
+ if self.address is None:
119
+ self.address = await find_printer()
120
+ self.client = BleakClient(self.address, timeout=20.0)
121
+ await self.client.connect()
122
+ await self.client.start_notify(NOTIFY_UUID, self._on_notify)
123
+ self.info = await self.get_info()
124
+ log.info("connected to %s: %r", self.address, self.info)
125
+
126
+ async def disconnect(self):
127
+ if self.client and self.client.is_connected:
128
+ await self.client.disconnect()
129
+
130
+ def _on_notify(self, _, data: bytearray):
131
+ data = bytes(data)
132
+ log.debug("<- %s", data.hex(" "))
133
+ if data[:1] == b"\xa7":
134
+ self._resume.clear()
135
+ elif data[:1] == b"\xa8":
136
+ self._resume.set()
137
+ self._replies.put_nowait(data)
138
+
139
+ async def _send(self, data: bytes):
140
+ log.debug("-> %s", data.hex(" ") if len(data) <= 16 else f"{data[:16].hex(' ')} ... ({len(data)} B)")
141
+ await self.client.write_gatt_char(WRITE_UUID, data, response=False)
142
+
143
+ async def _request(self, data: bytes, opcodes: bytes, timeout=3.0) -> bytes:
144
+ """Send a command and wait for a reply starting with one of `opcodes`."""
145
+ while not self._replies.empty():
146
+ self._replies.get_nowait()
147
+ await self._send(data)
148
+ loop = asyncio.get_running_loop()
149
+ deadline = loop.time() + timeout
150
+ while True:
151
+ remaining = deadline - loop.time()
152
+ if remaining <= 0:
153
+ raise PrinterError(f"no reply to {data[:1].hex()} (wanted {opcodes.hex()})")
154
+ try:
155
+ reply = await asyncio.wait_for(self._replies.get(), remaining)
156
+ except TimeoutError:
157
+ raise PrinterError(f"no reply to {data[:1].hex()} (wanted {opcodes.hex()})") from None
158
+ if reply[0] in opcodes:
159
+ return reply
160
+
161
+ async def get_info(self) -> Info:
162
+ return Info(await self._request(b"\x01", b"\x01"))
163
+
164
+ async def firmware_version(self) -> str:
165
+ return f"V{(await self._request(b'\xb2', b'\xb2'))[1]:x}"
166
+
167
+ async def power_off_minutes(self) -> int:
168
+ """Minutes of inactivity before the printer switches itself off."""
169
+ return (await self._request(b"\xb4", b"\xb4"))[1]
170
+
171
+ async def set_power_off_minutes(self, minutes: int) -> int:
172
+ """Set the auto power-off time (10-180 minutes); returns what the printer stored."""
173
+ minutes = max(POWER_OFF_MIN, min(POWER_OFF_MAX, int(minutes)))
174
+ return (await self._request(bytes([0xB5, minutes, 0]), b"\xb5"))[1]
175
+
176
+ async def print_image(self, img: Image.Image, density="normal", feed_lines=0,
177
+ label_mm: float | None = 30, gap_mm: float = 8.5):
178
+ """Print one label. `img` must be exactly print-head wide (see .width).
179
+
180
+ With `label_mm` set the job is sent as gap paper so the printer lines up
181
+ with the die-cut labels; pass label_mm=None for continuous tape.
182
+ """
183
+ level = DENSITY.get(density, density)
184
+ rows = rasterize(img, self.width)
185
+ rows += [bytes(self.width // 8)] * feed_lines
186
+ packets = build_packets(rows)
187
+
188
+ await self._send(b"\x04")
189
+ await asyncio.sleep(0.3)
190
+ info = await self.get_info()
191
+ if info.has_error:
192
+ raise PrinterError(f"printer reports an error (out of paper / cover open?): {info!r}")
193
+ if info.busy:
194
+ raise PrinterError(f"printer is busy: {info!r}")
195
+
196
+ total = len(rows)
197
+ if label_mm:
198
+ header = bytes([0x02, total & 0xFF, total >> 8, 0, 0, 0, 0x02, level,
199
+ round(label_mm), round(gap_mm * 10), 1])
200
+ else:
201
+ header = bytes([0x02, total & 0xFF, total >> 8, 0, 0, 0, 0x01, level])
202
+ reply = await self._request(header, b"\xa4\xa5", timeout=5.0)
203
+ log.info("header accepted: %s", reply.hex(" "))
204
+
205
+ self._resume.set()
206
+ await asyncio.sleep(2.0) # the app waits this long before the first packet
207
+ for i, pkt in enumerate(packets):
208
+ await self._wait_resume()
209
+ await self._send(pkt)
210
+ if i and (i * LINES_PER_PACKET) % 500 == 0:
211
+ await asyncio.sleep(2.0)
212
+ log.info("sent %d lines in %d packets", total, len(packets))
213
+
214
+ await self._wait_done()
215
+
216
+ async def _wait_resume(self, timeout=30.0):
217
+ try:
218
+ await asyncio.wait_for(self._resume.wait(), timeout)
219
+ except TimeoutError:
220
+ raise PrinterError("printer paused the data stream and never resumed") from None
221
+
222
+ async def _wait_done(self, timeout=60.0):
223
+ await asyncio.sleep(2.0)
224
+ loop = asyncio.get_running_loop()
225
+ deadline = loop.time() + timeout
226
+ while loop.time() < deadline:
227
+ try:
228
+ info = await self.get_info()
229
+ except PrinterError:
230
+ log.debug("status poll got no reply while printing, retrying")
231
+ continue
232
+ if info.has_error:
233
+ raise PrinterError(f"printer error during print: {info!r}")
234
+ if not info.busy:
235
+ return
236
+ await asyncio.sleep(1.0)
237
+ raise PrinterError("timed out waiting for the print to finish")
238
+
239
+ @property
240
+ def width(self) -> int:
241
+ return self.info.width if self.info else 96
wp9529/label.py ADDED
@@ -0,0 +1,213 @@
1
+ """Render labels as print-ready images for a 12 mm (96-dot) print head.
2
+
3
+ Part of the wp9529 package, and copied unchanged into the Home Assistant
4
+ integration (HA-WP9529), so it must not import anything from Home Assistant.
5
+
6
+ Layout, left to right along the label: [QR code] [MDI icon] [title / text].
7
+ Every part is optional; with no text, the QR code and icon are centred.
8
+ """
9
+ import json
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ import qrcode
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ DOTS_PER_MM = 8
17
+ ASSETS = Path(__file__).with_name("assets")
18
+ MDI_FONT = ASSETS / "materialdesignicons-webfont.ttf"
19
+ MDI_CODEPOINTS = ASSETS / "mdi-codepoints.json"
20
+ FONT_CANDIDATES = ["arialbd.ttf", "DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf"]
21
+
22
+
23
+ @lru_cache(maxsize=64)
24
+ def _font(size: int) -> ImageFont.FreeTypeFont:
25
+ for name in FONT_CANDIDATES:
26
+ try:
27
+ return ImageFont.truetype(name, size)
28
+ except OSError:
29
+ continue
30
+ return ImageFont.load_default(size)
31
+
32
+
33
+ @lru_cache(maxsize=1)
34
+ def _mdi_codepoints() -> dict[str, int]:
35
+ return json.loads(MDI_CODEPOINTS.read_text(encoding="utf-8"))
36
+
37
+
38
+ @lru_cache(maxsize=8)
39
+ def _mdi_font(size: int) -> ImageFont.FreeTypeFont:
40
+ return ImageFont.truetype(str(MDI_FONT), size)
41
+
42
+
43
+ def mdi_glyph(icon: str) -> str:
44
+ """Character for an MDI icon given as "mdi:tools" or "tools"."""
45
+ name = icon.removeprefix("mdi:").strip()
46
+ try:
47
+ return chr(_mdi_codepoints()[name])
48
+ except KeyError:
49
+ raise ValueError(f"unknown MDI icon: {icon!r}") from None
50
+
51
+
52
+ def _fit_font(draw, text, max_w, max_h, start=48):
53
+ size = start
54
+ while size > 8:
55
+ font = _font(size)
56
+ l, t, r, b = draw.textbbox((0, 0), text, font=font)
57
+ if r - l <= max_w and b - t <= max_h:
58
+ return font
59
+ size -= 1
60
+ return _font(8)
61
+
62
+
63
+ def _line_height(font) -> int:
64
+ l, t, r, b = font.getbbox("Ag")
65
+ return b - t + 2
66
+
67
+
68
+ def _split_long_word(draw, word, font, max_w) -> list[str]:
69
+ """Break a word wider than max_w into pieces that fit ("Korttisuoj", "at")."""
70
+ pieces, piece = [], ""
71
+ for ch in word:
72
+ if piece and draw.textlength(piece + ch, font=font) > max_w:
73
+ pieces.append(piece)
74
+ piece = ch
75
+ else:
76
+ piece += ch
77
+ return pieces + [piece] if piece else pieces
78
+
79
+
80
+ def _wrap(draw, text, font, max_w, break_words=False) -> list[str]:
81
+ words = []
82
+ for word in text.split():
83
+ too_wide = break_words and draw.textlength(word, font=font) > max_w
84
+ words += _split_long_word(draw, word, font, max_w) if too_wide else [word]
85
+ lines, line = [], ""
86
+ for word in words:
87
+ trial = f"{line} {word}".strip()
88
+ if draw.textlength(trial, font=font) <= max_w or not line:
89
+ line = trial
90
+ else:
91
+ lines.append(line)
92
+ line = word
93
+ return lines + [line] if line else lines
94
+
95
+
96
+ def _ellipsize(draw, text, font, max_w) -> str:
97
+ if draw.textlength(text, font=font) <= max_w:
98
+ return text
99
+ while text and draw.textlength(text + "…", font=font) > max_w:
100
+ text = text[:-1]
101
+ return text.rstrip() + "…"
102
+
103
+
104
+ def _fit_lines(draw, text, max_w, max_h, largest=22, smallest=12, max_lines=2):
105
+ """Largest font (not below `smallest`) that fits `text` in at most `max_lines`
106
+ lines; at the smallest size, overflow is cut with an ellipsis.
107
+
108
+ Words are only broken when whole words cannot fit at any size, and then into
109
+ as few lines as possible ("Korttisuo / jat", not "Kort / tisu / ojat").
110
+ """
111
+ attempts = [(False, max_lines)] + [(True, n) for n in range(1, max_lines + 1)]
112
+ for break_words, n_lines in attempts:
113
+ for size in range(largest, smallest - 1, -1):
114
+ font = _font(size)
115
+ lines = _wrap(draw, text, font, max_w, break_words)
116
+ if (len(lines) <= n_lines and _line_height(font) * len(lines) <= max_h
117
+ and all(draw.textlength(ln, font=font) <= max_w for ln in lines)):
118
+ return lines, font
119
+ font = _font(smallest)
120
+ n = max(1, min(max_lines, max_h // _line_height(font)))
121
+ lines = _wrap(draw, text, font, max_w, break_words=True)
122
+ head, rest = lines[:n - 1], " ".join(lines[n - 1:])
123
+ return [_ellipsize(draw, ln, font, max_w) for ln in head] + [_ellipsize(draw, rest, font, max_w)], font
124
+
125
+
126
+ def qr_image(data: str, size: int) -> Image.Image:
127
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=0, box_size=1)
128
+ qr.add_data(data)
129
+ qr.make(fit=True)
130
+ img = qr.make_image(fill_color="black", back_color="white").convert("L")
131
+ if img.width > size:
132
+ raise ValueError(f"QR data too long for a 12 mm label ({len(data)} characters)")
133
+ scale = max(1, size // img.width) # integer scaling keeps modules crisp
134
+ return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
135
+
136
+
137
+ def icon_image(icon: str, size: int) -> Image.Image:
138
+ glyph = mdi_glyph(icon)
139
+ img = Image.new("L", (size, size), 255)
140
+ ImageDraw.Draw(img).text((size / 2, size / 2), glyph, font=_mdi_font(size), fill=0, anchor="mm")
141
+ return img
142
+
143
+
144
+ def render_label(title: str = "", text: str = "", qr: str | None = None, icon: str | None = None,
145
+ length_mm: float = 30, head_px: int = 96, margin_px: int = 4,
146
+ end_margin_mm: float = 1.5, rotate: int = 90) -> Image.Image:
147
+ """Build a label image `head_px` wide and `length_mm` long, ready for print_image().
148
+
149
+ `title` is drawn large, `text` below it (or on its own, up to 3 lines),
150
+ `qr` is any string to encode, `icon` an MDI name like "mdi:tools".
151
+ `end_margin_mm` keeps content off the label's rounded ends.
152
+ """
153
+ if not (title or text or qr or icon):
154
+ raise ValueError("nothing to print: give a title, text, QR data or an icon")
155
+ length_px = int(length_mm * DOTS_PER_MM)
156
+ end_px = int(end_margin_mm * DOTS_PER_MM)
157
+ box = head_px - 2 * margin_px
158
+ canvas = Image.new("L", (length_px, head_px), 255)
159
+ draw = ImageDraw.Draw(canvas)
160
+
161
+ has_text = bool(title or text)
162
+ blocks = []
163
+ if qr:
164
+ blocks.append(qr_image(qr, box))
165
+ if icon:
166
+ # Sharing the label with a QR code and text, a full-height icon would
167
+ # leave almost no room for the text, so it shrinks to about 6 mm.
168
+ icon_px = 6 * DOTS_PER_MM if (qr and has_text) else box
169
+ blocks.append(icon_image(icon, icon_px))
170
+ blocks_w = sum(b.width for b in blocks) + 2 * margin_px * max(0, len(blocks) - 1)
171
+
172
+ x = end_px if has_text else max(end_px, (length_px - blocks_w) // 2)
173
+ for b in blocks:
174
+ canvas.paste(b, (x, (head_px - b.height) // 2))
175
+ x += b.width + margin_px * 2
176
+
177
+ text_w = length_px - x - end_px
178
+ if has_text and text_w < 3 * DOTS_PER_MM:
179
+ raise ValueError(f"no room for text on a {length_mm:g} mm label; drop the QR or the icon")
180
+
181
+ if title and text:
182
+ gap = 5
183
+ title_font = _fit_font(draw, title, text_w, head_px * 0.42)
184
+ tb = draw.textbbox((0, 0), title, font=title_font)
185
+ title_h = tb[3] - tb[1]
186
+ lines, text_font = _fit_lines(draw, text, text_w, box - title_h - gap)
187
+ line_h = _line_height(text_font)
188
+ y = (head_px - title_h - gap - line_h * len(lines)) // 2
189
+ draw.text((x - tb[0], y - tb[1]), title, font=title_font, fill=0)
190
+ y += title_h + gap
191
+ for line in lines:
192
+ draw.text((x, y), line, font=text_font, fill=0, anchor="lt")
193
+ y += line_h
194
+ elif title:
195
+ font = _fit_font(draw, title, text_w, box, start=64)
196
+ tb = draw.textbbox((0, 0), title, font=font)
197
+ draw.text((x - tb[0], (head_px - (tb[3] - tb[1])) // 2 - tb[1]), title, font=font, fill=0)
198
+ elif text:
199
+ lines, font = _fit_lines(draw, text, text_w, box, largest=40, max_lines=3)
200
+ line_h = _line_height(font)
201
+ y = (head_px - line_h * len(lines)) // 2
202
+ for line in lines:
203
+ draw.text((x, y), line, font=font, fill=0, anchor="lt")
204
+ y += line_h
205
+
206
+ return canvas.rotate(rotate, expand=True)
207
+
208
+
209
+ def asset_tag(asset_id: str, name: str = "", url: str | None = None, icon: str | None = None,
210
+ length_mm: float = 30, rotate: int = 90) -> Image.Image:
211
+ """Homebox tag: QR link, optional icon, asset ID large with the item name below."""
212
+ return render_label(title=asset_id, text=name, qr=url, icon=icon,
213
+ length_mm=length_mm, rotate=rotate)