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 0x43-Nonce, HalftoneType) bleiben gültig; nur die Implikation „welchen Drucker liefern wir aus" ist veraltet.

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

P83C

Auflösung

203 DPI (Hinweis: einfaches P83 ist 300 — unterschiedlich)

Farbe

Monochrom (1-bpp nach Dithering)

Kompression

false (Raster fester Größe)

Max. Breite

216 mm (Letter); A4 als Standard

Bildaufbereitung

Helligkeit +10, Unschärfemaske (filterseitig)

CUPS PPD

Xlife-P83(203DPI).ppd, Filter rastertosnailppli

Transport — BLE (nicht Classic SPP)

Aus dem tabs/index.*.js der Chrome-Erweiterung (navigator.bluetooth / GATT):

Rolle UUID (16-bit)

Service

0xFF00

Write (PPLI raus)

0xFF02 — writeValueWithoutResponse, 512-Byte-Chunks

Notify (abonniert)

0xFF03 (auch 0xFF01 existiert)

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:

  1. rastertosnailppli-x64 — lauffähiger, nicht-gestrippter x86-64-CUPS-Filter (unser Byte-Orakel)

  2. 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: 0x41 init → 0x0E config (×2) → 0x43 image descriptor → vier 0x03 raster bands → 0x00 → abschließender 0x0E feed/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 Zeile FF FF FF FF 00 00 00 00; top (obere 8 Zeilen schwarz) → schwarz nur in den ersten beiden 0x03-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/):

  1. Gleiches Bitmap → unterschiedliche Bytes. Das erneute Kodieren eines byte-identischen Rasters (die CUPS-.ras ist von Lauf zu Lauf SHA-stabil) liefert bei jedem Lauf ein anderes b0/b7/b8/b9 — drei aufeinanderfolgende Läufe ergaben 7d…d0 20 c3, bd…3c 14 2d, 03…47 c4 97. Eine Inhaltsprüfsumme ist deterministisch; dies ist es nicht.

  2. Nur 4 Bytes sind nicht-deterministisch. cmp -l zweier Streams mit identischer Eingabe unterscheidet sich an genau den Offsets 0x6e, 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.

  3. Der Filter zieht Zufall + Zeit. objdump -T zeigt Imports von time / localtime; strings zeigt std::random_device, das aus /dev/urandom liest.

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

b1 b2 LE

0x0004 konstant — Top/y-Offset (unverändert durch Höhe; Formatflag)

b3 b4 LE

x-Offset = (1728 − width) / 2 — zentriert das Bild auf dem 1728-px-Platen (216 mm @ 203 dpi)

b5 b6 LE

Bildbreite in px (64→40 00, 32→20 00, 128→80 00)

b10

0x0A Terminator

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)

  1. tauri-plugin-xlife-ble (Kotlin): FF00 scannen/verbinden, FF03 abonnieren, FF02 in 512-Byte-Chunks schreiben. Laufzeit-Berechtigungen automatisch via Device Owner gewährt.

  2. 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.

  3. 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, 0x77 innerhalb jedes 0x43-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-PrintService von 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).ppd verwenden)

  • extracted/chrome-app/ — entpackte Chrome-Erweiterung (JS-Referenz-Encoder + printer-mapping.json)

  • captures/ — die white/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

60:B5:69:A5:04:CC

Classic BR/EDR — SPP (1101) auf RFCOMM-Kanal 1. DIES IST DER FUNKTIONIERENDE DRUCK-TRANSPORT (siehe Korrektur unten).

60:B5:69:A5:04:CD

BLE / LE = Classic-Adresse + 1. Service 0000ff00, Write FF02. Druckt kleine Jobs, aber läuft bei großen Jobs über — bleak/BlueZ liefert keinen Write-without-Response-Backpressure.

⚠️ TRANSPORTKORREKTUR (die echte Antwort): über Classic Bluetooth SPP / RFCOMM drucken, nicht BLE. Einen RFCOMM-Socket mit 60:B5:69:A5:04:CC Kanal 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/FF02 war 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). Kein rfcomm bind nötig. Für Android (das eigentliche Ziel) ist das Äquivalent ein Classic BluetoothSocket mit der SPP-UUID 00001101-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

0000ff02

8

write-without-response

hier den PPLI-Byte-Stream schreiben

0000ff03

10

notify

Status-/Ack-Benachrichtigungen

0000ff01

5

notify

Status-Benachrichtigungen (der read() des Herstellers nutzt dies für Befehlsantworten)

  • Das OS hält den Classic-Link standardmäßig; man muss bluetoothctl disconnect …:CC (und untrust, um die Auto-Reconnect zu stoppen), bevor die LE-Seite erreichbar ist. (Bei Übergabe untrusted gelassen — …:CC re-`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 — 512 wirft org.bluez.Error.Failed auf 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 … und 9B 64 FF 00 1C <flag> …. Der 0xFF-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, wechselt 00 01 → 00 00; ein festhängender Job bleibt 00 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 eine AstraCommand-Klasse (CMD:AstraP laut PPD) mit status(), beep(), density(), openDrawer() usw. und einem getStatus(){lackPaper, allowSendData}. allowSendData ist das echte Backpressure-Signal; Status-Antworten sind durch 0x9B (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 der 0xFF/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 .ppli in Befehle / 0x43-Deskriptor / Raster-Bytes.

  • oracle.sh <img> — Bild → gstorasterrastertosnailppli → 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 das 00 00-Done-Flag.

  • ble_fc.py <ppli> [--hold N] — strikt 0101-gegateter Send (window=1).

  • ble_print.py — früherer credit-gegateter Sender (überholt; das Gating war unnötig).

  • venv/ — Python-Venv mit bleak (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.ras dann rastertosnailppli-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, gstoraster und rastertosnailppli, übergeben: … gstoraster 1 u t 1 "HalftoneType=None" file.pdf dann 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.