feat(daemon): /print-image — print a caller-rendered bitmap through the same encode->verify->print path

The seam for a separate label app to own LAYOUTS while this daemon stays the
single owner of the hardware. Takes a base64 PNG at media pixel size plus
sku/releaseId, and runs the identical guarded sequence: read, UHF WRITE, verify,
SET RFID OFF, geometry, print. A failed encode still spends no label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-09-06 14:34:22 +10:00
parent ce5b2b5db5
commit 7d9a2197b2

View File

@ -33,6 +33,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import label
from PIL import Image
import dymo as dymo_mod
from urovo import Urovo, UrovoError, epc_encode, epc_decode, status_text
@ -277,6 +278,64 @@ def do_read_raw():
return {'ok': any(banks.values()), 'banks': banks}
def do_print_image(body):
"""Print a caller-rendered 1-bit bitmap (base64 PNG), optionally encoding first.
This is the seam that lets a separate app own LAYOUTS while this daemon stays the single
owner of the hardware. do_print renders the Discogs-shaped label itself; anything else --
a book, a comic, a toy -- arrives here already drawn, at the media's pixel size, and goes
through the exact same encode -> verify -> SET RFID OFF -> print sequence. Same guards,
same "no label spent on a failed encode" promise."""
import base64, io
prof = label.profile(body.get('size'), web_mm=body.get('w'), pitch_mm=body.get('h'),
gap_mm=body.get('gap'))
encode = bool(body.get('encode', True))
sku = str(body.get('sku', '') or '').strip()
release_id = str(body.get('releaseId', '') or '0').strip() or '0'
try:
img = Image.open(io.BytesIO(base64.b64decode(body['png']))).convert('1')
except Exception as e:
return {'ok': False, 'error': 'bad png: %s' % e}
epc = None
if encode:
try:
epc = epc_encode(sku, release_id)
except ValueError as e:
return {'ok': False, 'error': str(e)}
with LOCK, Urovo() as p:
before = verified = None
if encode:
before = p.read_uhf()
p.write_uhf(epc)
time.sleep(1.0)
verified = p.read_uhf() == epc
if not verified:
return {'ok': False, 'error': 'RFID encode failed verification', 'epc': epc,
'epcBefore': before, 'readBack': p.read_uhf(),
'note': 'nothing printed -- no label wasted'}
p.cmd('SET RFID OFF'); time.sleep(0.4)
p.ribbon_off(); time.sleep(0.4)
if p.wait_ready(3.0) != 0x00:
st = p.recover()
if st != 0x00:
return {'ok': False, 'error': 'printer not ready', 'status': st,
'statusText': status_text(st)}
geometry(p, prof); p.cls()
p.write(label.to_tspl(img))
p.ribbon_off(); p.printlabel(1, 1)
time.sleep(2.0)
st = p.wait_ready(10.0)
nxt = None
try:
time.sleep(0.3); p.flush_in(); nxt = p.read_uhf()
except Exception:
pass
return {'ok': True, 'sku': sku, 'releaseId': release_id, 'epc': epc, 'epcBefore': before,
'verified': verified, 'nextTagAtAntenna': nxt, 'status': st,
'statusText': status_text(st), 'profile': prof['name'],
'size': [prof['web_mm'], prof['pitch_mm']], 'mode': 'image+encode' if encode else 'image'}
def do_calibrate():
with LOCK, Urovo() as p:
p.rfid_autocalibrate()
@ -371,6 +430,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json(do_print_paper(body))
if path == '/tag-and-print':
return self._json(do_tag_and_print(body))
if path == '/print-image':
return self._json(do_print_image(body))
handler = {'/calibrate': do_calibrate, '/recover': do_recover}.get(path)
if handler:
return self._json(handler())