Spike: Nativer Bluetooth-Druck zum Xlife P83C (Thermo-A4)
Datum: 2026-05-30 Status: GELÖST & hardwarevalidiert. Das vollständige 3-seitige Protokoll druckt zuverlässig aus Linux, ohne Hersteller-App, über Classic Bluetooth SPP/RFCOMM (NICHT BLE — siehe die Transportkorrektur). Gute Qualität bei HalftoneType=None. Siehe §Hardwarevalidierung & Übergabe für den Transport, den HalftoneType-Qualitätsregler, den Deckel-Vorbehalt und den Android-Plugin-Pfad (Classic BluetoothSocket, SPP-UUID …1101). Update 2026-05-30 (später): 0x43-Deskriptor vollständig per Oracle-Harness reverse-engineered — die vermutete „Prüfsumme" stellte sich als zufällige Nonce pro Job heraus, keine Prüfsumme (siehe §Der 0x43-Deskriptor — GELÖST). Keine CRC zu rekonstruieren; die einzige verbleibende Unbekannte ist eine Hardware-seitige Frage (validiert der Drucker die Nonce?), nur an einem physischen P83C beantwortbar. Ziel: Direkt aus der Tauri-Android-App auf einen Bluetooth-Xlife-P83C-Thermodrucker drucken, nativ, ohne Übergabe an die Hersteller-App „XLife".
|
Als ausgelieferter Drucker abgelöst. Dieser Spike ist ein korrekter historischer Reverse-Engineering-Nachweis, aber der Drucker, gegen den osd tatsächlich ausliefert, ist inzwischen der PeriPage A40 (siehe den A40-Spike, der diese Transport-/Encoder-Arbeit wiederverwendet). Die technischen Erkenntnisse hier (BLE-vs-SPP-Korrektur, die |
TL;DR
Machbar, geringes bis mittleres Risiko. Die Linux/Chrome-Treiber des Herstellers (auf dem mitgelieferten USB-Stick) liefern uns nahezu das gesamte Protokoll. Der native Pfad ist vollständig eigenständig — er benötigt keinen Android-System-PrintService und keine XLife-App zur Laufzeit:
protocol view → render 1-bpp raster @203dpi → PPLI command stream → BLE (FF00/FF02)
Ein dediziertes Tauri-Plugin (tauri-plugin-xlife-ble, Kotlin-BLE + PPLI-Encoder) erledigt die Arbeit. Device Owner / ES-Dashboard (Epic 9) ist weiterhin nützlich — aber nur, um BLUETOOTH_CONNECT/SCAN still zu gewähren und für die Kiosk-Sperre, nicht als Druck-Backend.
Der Drucker
Aus der assets/printer-mapping.json des Chrome-Treibers (der P83C-Eintrag ist explizit):
| Eigenschaft | Wert |
|---|---|
Modell |
|
Auflösung |
203 DPI (Hinweis: einfaches |
Farbe |
Monochrom (1-bpp nach Dithering) |
Kompression |
|
Max. Breite |
216 mm (Letter); A4 als Standard |
Bildaufbereitung |
Helligkeit +10, Unschärfemaske (filterseitig) |
CUPS PPD |
|
Transport — BLE (nicht Classic SPP)
Aus dem tabs/index.*.js der Chrome-Erweiterung (navigator.bluetooth / GATT):
| Rolle | UUID (16-bit) |
|---|---|
Service |
|
Write (PPLI raus) |
|
Notify (abonniert) |
|
Das Pairing filtert auf Service 0xFF00. Dies ist das Standard-FF00-BLE-Serial-Profil, auf Android via BluetoothGatt gut ausgetreten.
Protokoll — „PPLI"
Wir haben zwei Referenz-Encoder vom Stick:
-
rastertosnailppli-x64— lauffähiger, nicht-gestrippter x86-64-CUPS-Filter (unser Byte-Orakel) -
Der JS-Encoder innerhalb der Chrome-Erweiterung (lesbar)
Wie man Ground Truth neu erzeugt (keine Hersteller-Hardware nötig)
Die vollständige CUPS-Kette liegt auf einer normalen Linux-Kiste (gstoraster lebt in /usr/lib/cups/filter/, gs hat das cups-Device):
PPD="…/Xlife-P83(203DPI).ppd"
FILT="…/bin/rastertosnailppli-x64"
magick -density 203 -units pixelsperinch -size 64x16 xc:black black.pdf
PPD="$PPD" /usr/lib/cups/filter/gstoraster 1 u t 1 "" black.pdf > black.ras
PPD="$PPD" "$FILT" 1 u t 1 "" black.ras > black.ppli # ← captured PPLI bytes
Der Filter gibt zudem Zwischen-Bitmaps aus (/tmp/oriH*.bmp, /tmp/ditH*.bmp) gemäß seinem mvimg.sh-Debug-Helfer — praktisch, um zu prüfen, dass unsere Rasterung mit ihrer übereinstimmt.
Dekodierte Struktur (aus white/black/top-half/left-half-64×16-Mitschnitten)
-
64-Byte-Null-Präambel, dann Befehle, jeder gerahmt vom Präfix
9B 64(0x9B= CSI). -
Stream-Reihenfolge:
0x41init →0x0Econfig (×2) →0x43image descriptor → vier0x03raster bands →0x00→ abschließender0x0Efeed/finish. -
Raster-(
0x03)-Befehl: trägt 4 Zeilen Bitmap (8 Byte/Zeile × 4 = 32 Byte), ausgegeben oben→unten.-
1-bpp, MSB-first,
1= schwarz, Bytes links→rechts. -
Verifiziert:
left(linke 32 Spalten schwarz) → jede ZeileFF FF FF FF 00 00 00 00;top(obere 8 Zeilen schwarz) → schwarz nur in den ersten beiden0x03-Befehlen.
-
Der 0x43-Deskriptor — GELÖST
Die 0x43-Nutzlast umfasst 11 Byte. Die ursprünglichen 4 Mitschnitte sahen wie eine Prüfsumme aus:
b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 black: ec 04 00 40 03 40 00 20 bb 5e 0a white: f4 04 00 40 03 40 00 4b ef 02 0a top: 64 04 00 40 03 40 00 3a 65 98 0a left: a9 04 00 40 03 40 00 37 9c 02 0a
Es ist keine Prüfsumme. Beweis über die Oracle-Harness (~/xlife-spike/re/):
-
Gleiches Bitmap → unterschiedliche Bytes. Das erneute Kodieren eines byte-identischen Rasters (die CUPS-
.rasist von Lauf zu Lauf SHA-stabil) liefert bei jedem Lauf ein anderesb0/b7/b8/b9— drei aufeinanderfolgende Läufe ergaben7d…d0 20 c3,bd…3c 14 2d,03…47 c4 97. Eine Inhaltsprüfsumme ist deterministisch; dies ist es nicht. -
Nur 4 Bytes sind nicht-deterministisch.
cmp -lzweier Streams mit identischer Eingabe unterscheidet sich an genau den Offsets0x6e, 0x75, 0x76, 0x77=b0, b7, b8, b9. Nichts sonst im 336-Byte-Stream ändert sich, es gibt also keine übertragene Nonce/keinen Seed anderswo, über die sie prüfsummieren könnten — es sind eigenständige Zufallswerte. -
Der Filter zieht Zufall + Zeit.
objdump -Tzeigt Imports vontime/localtime;stringszeigtstd::random_device, das aus/dev/urandomliest.
Fazit: b0, b7, b8, b9 sind 4 zufällige Nonce-Bytes pro Job. Der native Encoder muss keine Prüfsumme berechnen — 4 Zufallsbytes ausgeben (oder auf Hardware Nullen testen).
Die verbleibenden 6 Bytes sind deterministische Geometrie, dekodiert durch Abtasten von Breiten/Höhen:
| Feld | Bedeutung |
|---|---|
|
|
|
x-Offset = |
|
Bildbreite in px (64→ |
|
|
Die Bildhöhe steht nicht im Deskriptor — sie ist durch die Anzahl der 0x03-Raster-Bänder impliziert (4 Zeilen pro Band).
Offene Frage (nur Hardware): validiert der P83C die Nonce, oder ignoriert er sie / behandelt sie als opake Job-ID? An einem physischen Drucker klären, indem man einen Stream mit auf 00 gezwungenen b0/b7/b8/b9 (oder einer festen Konstante) sendet und bestätigt, dass er dennoch druckt. Falls er ablehnt, ist die Nonce-Ableitung (wahrscheinlich ein einfacher random_device-Zug, möglicherweise zeit-geseedet) das Einzige, was noch zu treffen wäre, und sie müsste für den Drucker beobachtbar sein — was das Ergebnis „keine anderen variierenden Bytes" verneint. Starke Prior: der Drucker ignoriert sie.
Vorgeschlagene Implementierung (ausstehend Design- + Abhängigkeits-Freigabe)
-
tauri-plugin-xlife-ble(Kotlin): FF00 scannen/verbinden, FF03 abonnieren, FF02 in 512-Byte-Chunks schreiben. Laufzeit-Berechtigungen automatisch via Device Owner gewährt. -
PPLI-Encoder (Rust oder Kotlin): das Protokoll-Drucklayout rendern → 1-bpp-Raster bei 203 dpi / 216 mm Breite → den
9B 64-Command-Stream oben ausgeben. -
Fixture-Test: eine bekannte Seite kodieren, Byte-Gleichheit gegen die
rastertosnailppli-Ausgabe behaupten (TDD; der Filter ist eingecheckte Testdaten, nicht in der App weiterverteilt). Muss die 4 Nonce-Offsets maskieren (0x6e, 0x75, 0x76, 0x77innerhalb jedes0x43-Befehls) — das Orakel randomisiert sie pro Lauf, ein naiver Byte-Diff schlägt dort also immer fehl. Alles außer diesen Positionen vergleichen und separat behaupten, dass unser Encoder dort 4 Bytes schreibt (Wert irrelevant bis zur Hardware-Prüfung).
Ausgeschlossene Blocker
-
❌ Android-System-
PrintServicevon XLife — XLife ist ein In-App-Drucker, kein System-Druck-Backend; auf dem Direkt-BLE-Pfad ohnehin irrelevant. -
❌ Classic Bluetooth SPP — es ist BLE.
-
❌
window.print()auf Tauri Android — bestätigt No-op (tauri#11933); ein natives Plugin ist ohnehin erforderlich.
Quell-Artefakte (persistiert — übersteht Reboot)
Alles bewahrt in ~/xlife-spike/ (außerhalb des Repos, nicht committet — Hersteller-Binaries unter EULA):
-
Linux/printer-driver-xlife_3.13.35_all.deb+.rpm— originaler Hersteller-Treiber (Source of Truth) -
Chrome/xlife-0-1-3-prod.zip— originale Chrome-App -
extracted/bin/— die lauffähigen Filter-Binaries, inkl.rastertosnailppli-x64(das Byte-Orakel) -
extracted/ppds/— alle Xlife-PPDs (Xlife-P83(203DPI).ppdverwenden) -
extracted/chrome-app/— entpackte Chrome-Erweiterung (JS-Referenz-Encoder +printer-mapping.json) -
captures/— diewhite/black/top/left-.ppli-Dekodier-Samples + Test-PDFs + Filter-Logs
Originale auch auf USB-Stick 9C9B-E5D5. Die Scratch-Verzeichnisse /tmp/ppli + /tmp/xlife-driver werden beim Reboot gelöscht — über die Befehle oben neu erzeugen, auf ~/xlife-spike/extracted zeigend.
Hardwarevalidierung & Übergabe (2026-05-30, Live-Drucker)
End-to-end gegen einen physischen Drucker validiert (Marke Vretti, Modell P83C, A4-Thermo) von diesem Linux-Laptop über BLE — keine Hersteller-App, kein Android. Ergebnis: der native Druckpfad funktioniert, inklusive des vollständigen 3-seitigen Protokolls und der vollbreiten Testseite. Die Machbarkeit steht nicht mehr in Frage.
Geräteidentität & Transport (auf Hardware BESTÄTIGT)
Der Drucker ist Dual-Mode und annonciert zwei Adressen:
| Adresse | Rolle |
|---|---|
|
Classic BR/EDR — SPP ( |
|
BLE / LE = Classic-Adresse + 1. Service |
⚠️ TRANSPORTKORREKTUR (die echte Antwort): über Classic Bluetooth SPP / RFCOMM drucken, nicht BLE. Einen RFCOMM-Socket mit
60:B5:69:A5:04:CCKanal 1 verbinden und den PPLI-Stream schreiben — es ist ein zuverlässiger, flussgesteuerter Stream, sodass das vollständige 1,5-MB- / 3-seitige Protokoll in ~33 s zuverlässig und wiederholbar druckt (mehrfach verifiziert). Das frühere Ergebnis „RFCOMM verschluckt Bytes" war ein Lid-offen-Artefakt, kein Transportfehler. BLE/FF02war ein Ablenkungsmanöver für große Jobs — es hat auf Linux keinen Backpressure und der Drucker hält übergroße Jobs stillschweigend zurück. Python:socket.socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM); s.connect"60:B5:69:A5:04:CC", 1; s.sendall(ppli). Keinrfcomm bindnötig. Für Android (das eigentliche Ziel) ist das Äquivalent ein ClassicBluetoothSocketmit der SPP-UUID00001101-0000-1000-8000-00805f9b34fb— weit einfacher als das zuerst konzipierte BLE-GATT-Plugin und es erbt die Flusskontrolle von SPP.
GATT an der LE-Adresse:
| Char | Handle | Props | Nutzung |
|---|---|---|---|
|
8 |
write-without-response |
hier den PPLI-Byte-Stream schreiben |
|
10 |
notify |
Status-/Ack-Benachrichtigungen |
|
5 |
notify |
Status-Benachrichtigungen (der |
-
Das OS hält den Classic-Link standardmäßig; man muss
bluetoothctl disconnect …:CC(unduntrust, um die Auto-Reconnect zu stoppen), bevor die LE-Seite erreichbar ist. (Bei Übergabe untrusted gelassen —…:CCre-`trust`en, wenn OS-Audio-Auto-Connect zurück soll.) -
BlueZ verhandelt ATT-MTU 497 → max. Write-without-Response-Payload = 494 Byte. Der Hersteller nutzt 512 (Chrome verhandelt eine größere MTU); die Chunk-Größe ist für den Drucker transparent (PPLI rahmt sich selbst via
9B 64), sodass 494-Byte-Chunks in Ordnung sind —512wirftorg.bluez.Error.Failedauf BlueZ.
Was druckt, und die betrieblichen Eigenheiten
-
Kleine Seiten drucken sauber und korrekt — Rasterung + Geometriemodell validiert (z. B. dekodieren die 3 Seiten des Protokolls zu width=1680, xoff=24 → zentriert auf dem 1728-px-Platen).
-
Das vollständige 3-seitige Protokoll UND die vollbreite Karte HABEN gedruckt. Aber große/gepufferte Jobs brauchten menschliches Eingreifen am Drucker: einen Power-Cycle und einen Frontknopf-Druck, und einige Jobs druckten später, von selbst (gepuffert, dann geflusht). Das scheinbare „Hängen bei Jobs > ~11 KB", vom Host gesehen, ist also keine harte Protokoll-Größenbeschränkung — es ist der Drucker, der auf einen Druck-Trigger / Papier / Deckelzustand wartet, den der Frontknopf + Power-Zustand auflöst.
-
Der Deckel-Mikroschalter ist unzuverlässig. Jobs, die mit nicht vollständig eingerastetem Deckel empfangen werden, puffern und drucken, sobald er ordentlich geschlossen ist (dies verursachte mehrere „nichts gedruckt"-Episoden und einen späteren spontanen Doppeldruck). Den Deckelzustand als erstklassige Variable behandeln.
-
Recovery aus einem festhängenden/eingerasteten Zustand = Drucker power-cyclen (löscht Puffer + jeden eingerasteten Deckel-/Nicht-bereit-Fehler), dann nimmt er frische Jobs an.
Die Status- / Flusskontroll-Schicht (die eigentliche „nächste Schicht", teilweise RE’d)
Rohes PPLI-Dumping funktioniert für kleine Jobs; robuster Druck großer Jobs nutzt eine Command-/Status-Schicht:
-
Der Drucker gibt PPLI-gerahmte Status-Benachrichtigungen auf FF01/FF03 aus:
9B 64 F9 …und9B 64 FF 00 1C <flag> …. Der0xFF-Status-Frame trägt ein Abschlussflag (00 01= in Arbeit/nicht fertig,00 00= fertig) und einen Zähler, der pro gedruckter Einheit vorrückt, plus er echot xoff+width der aktuellen Seite (z. B.18 00=24,90 06=1680). Ein Job, der druckt, wechselt00 01 → 00 00; ein festhängender Job bleibt00 01. -
Eigenständige
01 01-Benachrichtigungen kommen ~1 pro 494-Byte-Chunk — ein Empfangs-Ack pro Chunk (NICHT für die Flusskontrolle erforderlich; der Hersteller gatet nicht darauf). -
Das JS des Herstellers (
extracted/chrome-app/tabs/index.7d315be3.js) hat eineAstraCommand-Klasse (CMD:AstraPlaut PPD) mitstatus(),beep(),density(),openDrawer()usw. und einemgetStatus()→{lackPaper, allowSendData}.allowSendDataist das echte Backpressure-Signal; Status-Antworten sind durch0x9B(155) terminiert. Der Hersteller-write(e)feuert einfach 512-Byte-writeValueWithoutResponse-Chunks ab und verlässt sich auf Web-Bluetooth- / L2CAP-Credit-Backpressure — was bleak/BlueZ nicht bereitstellt (Fire-and-Forget). Auf Android (dem eigentlichen Ziel) stellt der BLE-Stack diesen Backpressure wie Chrome bereit, sodass der host-seitige Überlauf, den wir auf Linux trafen, dort unwahrscheinlich reproduziert. -
Noch offenes RE (für einen produktionsreifen Encoder): die Request-Bytes von
AstraCommand.status()und die Felder der0xFF/0xF9-Status-Frames dekodieren (lackPaper/allowSendData-Mapping) und bestätigen, ob große Seiten explizite Segmentierung brauchen oder nur ordentlichen Backpressure. Nicht erforderlich, um Druck zu beweisen — erforderlich, um ihn robust & unbeaufsichtigt zu machen.
RE-Harness (wiederverwendbar — ~/xlife-spike/re/, außerhalb des Repos)
-
parse.py— parst eine.ppliin Befehle /0x43-Deskriptor / Raster-Bytes. -
oracle.sh <img>— Bild →gstoraster→rastertosnailppli→ Print-Deskriptor+Raster (das Byte-Orakel). -
crack.py— Prüfsummen-Brute-Forcer (genutzt, um zu beweisen, dass die Nonce keine Prüfsumme ist). -
ble_blast.py <ppli> [--zero-nonce] [--hold N]— hersteller-treuer sequentieller 494-B-Blast, wartet auf das00 00-Done-Flag. -
ble_fc.py <ppli> [--hold N]— strikt0101-gegateter Send (window=1). -
ble_print.py— früherer credit-gegateter Sender (überholt; das Gating war unnötig). -
venv/— Python-Venv mitbleak(einziges neu hinzugefügtes Tool; isoliert, keine Projekt-Abhängigkeit). -
Generierte Samples:
testprint.ppli(512×160, druckt),wide.ppli(1680×200),tall.ppli(512×660),proto.ppli(das 3-seitige Protokoll, 1,51 MB). -
Protokoll-PPLI neu erzeugen:
gstoraster 1 u t 1 "" Protokoll.pdf > p.rasdannrastertosnailppli-x64 1 u t 1 "" p.ras > p.ppli(PPD-Env-Var =Xlife-P83(203DPI).ppd).
Druckqualität — Halftone (auf Hardware BESTÄTIGT)
Der Drucker ist 1-bit (reiner Schwarz/Weiß-Punkt) bei 203 dpi. Der HalftoneType der PPD ist der zentrale Qualitätsregler, und sein *Default Stucki dithert alles — lässt Text/Tabellenlinien ausgefranst aussehen und farbige Füllungen kommen als seltsames graues Punktmuster heraus.
-
HalftoneType=None(Threshold) — empfohlen. Klarer Text + Tabellenlinien, volle Füllungen; farbige Bereiche schnappen auf volles Schwarz oder Weiß (kein graues Halftone). Kompromiss: eine freihändige Unterschrift wird ebenfalls gethresholded (verliert Glätte) — akzeptabel für ein klinisches Protokoll. -
FloydSteinberg— feineres Dither, besser für Fotos/Unterschriften, Linien noch leicht gedithert. -
Über den CUPS-Optionsstring (5. Argument) an beide,
gstorasterundrastertosnailppli, übergeben:… gstoraster 1 u t 1 "HalftoneType=None" file.pdfdann der Filter mit derselben Option.
Der größte Qualitätsgewinn ist app-seitig: eine Thermo-Mono-Variante des Typst-Protokoll-PDFs designen — reines S/W, Tabellenlinien ≥ 0,75—1 pt (Haarlinien < 0,4 pt fallen unter einen 203-dpi-Punkt und fallen weg), farbige Status-Pills als Schwarz-auf-Weiß / invertiert Weiß-auf-Schwarz gerendert statt farbiger Füllungen. Das mit HalftoneType=None rendern → klare Tabellen + Text, kein Grau. Brightness und eine langsamere PrintSpeed (Default 7) dunkeln den Brand ab, wenn Linien schwach wirken.
Fazit zu „den Drucker behalten?"
Behalten — GELÖST. Das vollständige 3-seitige Protokoll druckt zuverlässig und wiederholbar über Classic SPP/RFCOMM in ~33 s, mit guter Qualität bei HalftoneType=None. Transport, Kodierung und Qualitätsabstimmung sind alle verstanden. Verbleibende Arbeit ist ein begrenztes Tauri-Android-Plugin mit einem Classic BluetoothSocket (SPP-UUID …1101) — nicht das zuerst konzipierte BLE-GATT-Plugin — das die PPLI aus dem (bereits verstandenen) Encoder streamt, plus ein Thermo-Mono-PDF-Stylesheet. Der unzuverlässige Deckel (Jobs puffern, bis er eingerastet ist; Statusbit handup) ist ein Hardware-/Nutzungs-Vorbehalt fürs Feld, kein Software-Blocker.