Spike: Nativer Bluetooth-Druck auf den PeriPage A40 (Thermo-A4)
Datum: 2026-06-26 Status: GELÖST & hardwarevalidiert (Clean-Room). Wir haben unser eigenes rohes GS v 0-Raster von einem Linux-Laptop über RFCOMM auf einen physischen A40 (PPG_A40_C2EA, fw V1.4.3_SD) gedruckt — ohne Hersteller-App — und damit Transport, Polarität, Dichte, Ausrichtung und Breite bestätigt. Das Protokoll des A40 ist einfacher als das des bereits gelösten Xlife P83C (keine Nonce pro Auftrag, keine Prüfsumme, ein Standard-ESC/POS-Raster-Opcode), der Transport ist derselbe Classic-Bluetooth-SPP/RFCOMM-Pfad, den wir schon beim P83C nachgewiesen haben, und der Typst → pixmap Rasterizer der App existiert bereits (src-tauri/src/pdf/world.rs::render_pages_png). Das einzige wesentliche Risiko ist die Lizenzierung: jede öffentliche A40-Implementierung ist GPL-3.0 oder all-rights-reserved, und osd ist proprietär — deshalb haben wir Clean-Room aus Protokollfakten + unserem eigenen Mitschnitt aufgebaut, niemals aus deren Code. Siehe §Hardwarevalidierung. Ziel: Das Protokoll direkt aus der Tauri-Android-App auf einen Bluetooth-PeriPage-A40-Thermodrucker drucken, nativ, ohne die Hersteller-App "PeriPage".
Siehe den Schwester-Spike Nativer Bluetooth-Druck auf den Xlife P83C für die Vorlage zu Transport/Encoder/Qualität — der Großteil dieser Maschinerie wird hier wiederverwendet.
|
Statusaktualisierung — ausgeliefert. Dies ist kein Vorschlag mehr: die |
TL;DR
protocol view → Typst render → tiny-skia Pixmap → 1-bpp threshold @ 216 B/row
→ PeriPage command stream (10 ff … reset · GS v 0 raster · ESC J feed)
→ Classic Bluetooth SPP / RFCOMM channel 1 (Android BluetoothSocket)
Ein dediziertes tauri-plugin-peripage (nach dem Muster von tauri-plugin-kiosk geklont: Rust-Command-Bus → Kotlin-@Commands → testbare Ops-Naht) übernimmt den Transport; ein reines Rust-Crate peripage-encoder (Clean-Room) erzeugt die Bytes. Keine neuen schweren Abhängigkeiten auf der Rust-Seite — Typst/tiny-skia sind bereits in src-tauri vorhanden. Die einzige neue Plattform-Fähigkeit ist die Android-Berechtigung BLUETOOTH_CONNECT + ein Classic BluetoothSocket.
⚠️ Lizenzierung — zuerst lesen (dies ist der ganze Grund für "Clean-Room")
osd wird unter einer proprietären Lizenz vertrieben (/LICENSE — "ES Dashboard — Proprietary License"). Das macht die Lizenzierungsbeschränkung zu einem harten Blocker, keiner Nettigkeit:
| Bibliothek | Modelle | Lizenz | Nutzbar? |
|---|---|---|---|
|
A6/A6+/A40/A40+ |
GPL-3.0-or-later (LICENSE-Datei + Quell-Header pro Datei + |
NEIN — beim Coden nicht lesen |
|
gleich (Fork des obigen) |
GPL-3.0 |
NEIN |
|
A40/A40+ |
Keine Lizenz = all rights reserved |
NEIN (schlimmer als GPL — gar keine Rechteeinräumung) |
|
nur A6/A6+ |
GPL-3.0 |
entfällt (kein A40) |
|
nur A2 |
MIT |
entfällt (falsches Modell; die einzige gefundene permissive) |
Es gibt keine permissive (MIT/BSD/Apache) A40-Implementierung, aus der man vendorn könnte. Das Einbinden oder Übersetzen von GPL-3.0-Code in eine proprietäre App verletzt die GPL; das Einbinden des unlizenzierten Java bringt uns nichts. Also:
Clean-Room-Regeln für diese Arbeit
-
Wer den Rust/Kotlin-Encoder schreibt, darf
bitrate16/peripage-python, seinen Fork oderperipage-javanicht lesen. Als verbotene Quelle behandeln. -
Wir dürfen uns auf Protokollfakten stützen — Command-Opcodes, Geometrie, Bit-Reihenfolge. Funktionale Schnittstellenfakten, die zur Interoperabilität mit einem Gerät erforderlich sind, sind nicht urheberrechtlich schützbar (Idee/Ausdruck; Interoperabilität). Diese Fakten sind in §Protokoll unten festgehalten.
-
Herkunftsnachweis: jeden Fakt gegen unseren eigenen btsnoop-HCI-Mitschnitt der offiziellen PeriPage-App, die auf unseren A40 druckt, bestätigen. Das Dokumentieren unseres eigenen Mitschnitts bedeutet, dass der Encoder aus der Beobachtung der Leitung abgeleitet ist, nicht aus deren Ausdruck — die stärkste Clean-Position. Das Mitschnitt-Log liegt außerhalb des proprietären Repos (siehe §Workspace).
-
Die Entwicklung des Clean-Room-Encoders im separaten Workspace
~/Projects/spikes/thermalprint/halten (spiegelt, wie der P83C~/xlife-spike/außerhalb des Repos hielt), und nur das fertige, mitschnittvalidierte Crate in osd kopieren.
Der Drucker (A40) — bestätigte Geometrie
| Eigenschaft | Wert | Konfidenz |
|---|---|---|
Modellfamilie |
A40 ist das breite A4/Letter-Modell (kein separates kleines "A40") |
Bestätigt |
Druckbreite |
1728 Punkte = 216 Bytes/Zeile (A40+ = 1848 Punkte = 231 B/Zeile) |
Bestätigt (2 unabhängige Repos + Java-Enum |
DPI |
203 oder 304 dpi je nach Dichte-Variante ("300 dpi" bei Wiederverkäufern = gerundete 304) |
Bestätigt (offiziell); der Wert unserer Einheit über |
Farbe |
Monochrom, 1-bpp, MSB-first, Bit 1 = schwarz (Hersteller invertiert vorab) |
Bestätigt |
Kompression |
Keine — rohe Zeilen, null-aufgefüllt/abgeschnitten auf 216 B; keine Prüfsumme |
Bestätigt |
Papierbreiten |
210 / 107 / 77 / 56 mm (A4 / 4" / 3" / 2") über verstellbare Klammern; volles A4 = 210×297 mm |
Bestätigt (offiziell) |
Puffergröße |
Unbekannt — zeilenweise gechunkt streamen, keinen vollen Seitenpuffer annehmen |
Unbekannt |
Bei ~203 dpi ist der 1728-Punkt-Kopf ≈ 216 mm breit. Für das Typst-Raster wollen wir pixel_per_pt ≈ 203/72 ≈ 2.82 für eine 203-dpi-Einheit (oder ≈ 4.22 für eine 304-dpi-Einheit) — nach der Firmware-Abfrage festnageln. render_pages_png nimmt pixel_per_pt bereits entgegen.
Transport — Classic Bluetooth SPP / RFCOMM, Kanal 1 (NICHT BLE)
Dieselbe Schlussfolgerung, auf die der P83C kam. Die Hersteller-Bibliotheken verbinden einen Classic-RFCOMM-Socket mit Kanal 1 (bluetooth.BluetoothSocket(RFCOMM); connectmac, 1); der Drucker bewirbt den Standard-SPP-Dienst. Keine GATT/BLE-Charakteristiken gelten für den A40 (die FF00/FF02-UUIDs vom P83C und die 0000aeXX/1f 11-Idiome von "cat printers" sind nicht verwandt — nicht verwenden).
-
Android: Classic
BluetoothSocketviadevice.createRfcommSocketToServiceRecord(UUID 00001101-0000-1000-8000-00805f9b34fb), dannsocket.outputStream.write(stream). Erbt die SPP-Flusskontrolle (den Gegendruck, den der P83C-Spike für große Aufträge als essenziell befand). Der BT-Name sieht aus wiePeriPage_XXXX. -
Berechtigung:
android.permission.BLUETOOTH_CONNECT(API 31+) zum Plugin-Manifest hinzufügen; als Device Owner können wir sie stillschweigend automatisch gewähren (derselbe Pfad wie die WLAN-Berechtigungen intauri-plugin-kiosk). Die Kopplung kann durch die Kiosk-Richtlinie vorab bereitgestellt werden. -
Linux-Desk-Validierung (kein Android nötig): das P83C-Harness-Muster funktioniert —
socket.socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM); s.connectmac, 1; s.sendall(stream).
Protokoll — 10 ff-Steuerung + zwei ESC/POS-Opcodes (Clean-Room-Fakten)
PeriPage ist kein Drucker der ESC/POS-Sprache (kein ESC @, kein ESC ), aber sein Rasterpfad verwendet zwei echte ESC/POS-Opcodes wieder, eingehüllt in proprietäres 10 ff-Steuer-Framing. *1-bpp, MSB-first, nirgends eine Prüfsumme. Mentales Modell: "10 ff-Umhüllung um GS v 0 + ESC J."
Steuerbefehle (alle mit 10 ff präfigiert)
| Funktion | Bytes | |--|--| | Reset / Wecken (nach Connect erforderlich; auch vor jedem Raster-Chunk erneut gesendet) | 10 ff fe 01 + zwölf 00 | | Firmware abrufen (nagelt dpi fest: V2.11_304dpi) | 10 ff 20 f1 | | Hardware-Info abrufen (Chip/Datum) | 10 ff 30 10 | | Batterie-% abrufen (resp[1] lesen) | 10 ff 50 f1 | | Gerätename abrufen | 10 ff 30 11 | | Schwärze / Konzentration setzen (0/1/2) | 10 ff 10 00 0{0,1,2} | | Abschalt-Timeout setzen (Minuten, u16 BE) | 10 ff 12 + hi lo | | Papier vorschieben (ESC/POS ESC J, 1 Größen-Byte) | 1b 4a + n |
⚠️ Sende nicht 10 ff 70 f1 00 (Voll-Info-Abfrage) vor einem Druck — es beschädigt das nächste Bild. Vermeide Statusabfragen, die zwischen das Raster eingestreut sind.
Rasterbefehl — ESC/POS GS v 0 = 1d 76 30
Hauptform (gechunkt), pro Chunk von bis zu 0xff Zeilen:
1d 76 30 00 <bytesPerRow=0xD8> 00 <chunkHeightRows> 00 <pixel data: rows × 216 B>
-
<bytesPerRow>=0xD8(216) für A40 (0xE7/231 für A40+). -
Hohe Seiten in ≤255-Zeilen-Chunks aufteilen;
10 ff fe 01+12×00-Reset wird vor jedem Chunk gesendet; eine kleine Verzögerung zwischen den Chunks einfügen (kleiner Druckerpuffer — die Bibliotheken schlafen). -
Auf dem A6 gesehener Auftragsende-Schwanz:
1b 4a 40(Vorschub 64), dann10 ff fe 45.
Rasterung (1-bpp)
Die tiny-skia Pixmap auf 1-bpp schwellwerten, MSB-first, Bit 1 = schwarz, jede Zeile exakt auf 216 Bytes gepackt (rechts mit 0x00 auffüllen). Die Polarität muss auf der Hardware verifiziert werden (der Hersteller invertiert Graustufen vor convert('1'); wir bestätigen, in welche Richtung Schwarz brennt). Die P83C-Qualitätslektionen wiederverwenden: Schwellwert im Stil HalftoneType=None für scharfe Tabellen/Text, Tabellenlinien ≥ 0,75—1 pt, damit Haarlinien einen Punkt überleben; eine Floyd—Steinberg-Option für Unterschriften.
Vorgeschlagene Umsetzung (ausstehend Design + Abhängigkeits-Freigabe)
Zwei Artefakte, die den P83C-Plan und die bestehende tauri-plugin-kiosk-Form spiegeln:
-
peripage-encoder— reines Rust-Crate ohne Android (Clean-Room, im separaten Workspace prototypisiert). Eingabe:Pixmap(oder&[u8]1-bpp-Zeilen) + Modell-Enum. Ausgabe: derVec<u8>-Command-Stream. Reine Funktion ⇒ trivial unit-testbar.-
Fixture-Test: Byte-Gleichheit unseres
GS v 0-Framings + Reset-Handshakes gegen unseren eigenen btsnoop-Mitschnitt behaupten (eingecheckt im Spike-Workspace, NICHT im proprietären Repo). Anders als beim P83C gibt es keine Nonce zu maskieren — der Stream ist deterministisch, also funktioniert ein direkter Byte-Diff.
-
-
tauri-plugin-peripage(Kotlin + Rust-Bus): RFCOMM ch.1 scannen/koppeln/verbinden, den Stream mit SPP-Gegendruck schreiben, Batterie-/Firmware-Status bereitstellen.PeripageController+PeripageOps-Naht, damit ein JVM-Unit-Test die Orchestrierung mit einem Fake ausübt (dasselbe Muster wieWifiController).BLUETOOTH_CONNECThinzufügen, über Device Owner automatisch gewährt. -
Thermo-Mono-Typst-Variante des Protokoll-PDFs (aus dem P83C-Urteil übernommen): reines S/W, Linien ≥ 0,75 pt, Status-Pillen als Schwarz-auf-Weiß / invertiert, gerendert über das bestehende
render_pages_pngbei denpixel_per_ptdes A40.
Abhängigkeiten: der Rust-Encoder braucht nichts Neues (Typst/tiny-skia bereits vorhanden). Die Kotlin-Seite nutzt die Plattform-API android.bluetooth — keine neuen Gradle-Abhängigkeiten. ⇒ Das Human-in-the-loop-Abhängigkeitsgate sollte sauber sein, aber vor dem Anfassen von Manifesten bestätigen.
Offene Fragen — auf Hardware klären (braucht das Telefon / einen gekoppelten A40)
-
Pixel-Polarität — brennt Bit 1 schwarz, oder invertieren wir? (Ein kleiner Mitschnitt beantwortet es.)
-
Genaue dpi unserer Einheit —
10 ff 20 f1→ 203 vs. 304 → setztpixel_per_pt. -
Reset-Takt / Verzögerung zwischen Chunks — das Minimum, das eine volle A4-Seite ohne Pufferüberlauf druckt (der P83C brauchte ~menschliches Eingreifen; die A40-Puffergröße ist undokumentiert).
-
Auftragsende-Schwanz — bestätigen, dass der A6-Schwanz
1b 4a 40 10 ff fe 45auf A40 zutrifft. -
Wörtliche SPP-UUID vs. Kanal — RFCOMM ch.1 /
…1101auf unserer Einheit bestätigen.
Was ich von dir brauche (unmittelbar nächster Schritt — der Mitschnitt)
Um den Clean-Room-Herkunftsnachweis zu starten und die offenen Fragen zu beantworten, nutzt der erste konkrete Schritt dein Android-Telefon über USB-Debugging:
-
Das Telefon mit dem PeriPage A40 koppeln, ein Protokoll/Testbild aus der offiziellen PeriPage-App drucken.
-
Das btsnoop-HCI-Log ziehen ("Bluetooth-HCI-Snoop-Log" in den Entwickleroptionen aktivieren →
adb bugreportoder/sdcard/.../btsnoop_hci.logziehen), damit wir den exakten Handshake + dasGS v 0-Framing auf unserem Gerät erfassen. -
Aus diesem Mitschnitt (a) bestätigen wir Polarität/dpi/Schwanz, (b) schreiben den Encoder-Fixture-Test und (c) prototyp-drucken von diesem Linux-Laptop über RFCOMM, bevor wir das Android-Plugin anfassen.
Ausgeschlossene Blocker
-
❌ GPL-Kontamination — vermieden durch Clean-Room + Eigen-Mitschnitt-Herkunft (dieses Dokument).
-
❌ BLE/GATT-Komplexität — A40 ist Classic SPP; einfacher als zunächst befürchtet.
-
❌ Nonce pro Auftrag / Prüfsumme (das P83C-Kopfzerbrechen) — keine bei PeriPage; deterministischer Stream.
-
❌ Rasterizer-Lücke — bereits gelöst (
render_pages_png→ tiny-skia Pixmap). -
❌
window.print()auf Tauri Android — als No-Op bestätigt (tauri#11933); natives Plugin ohnehin erforderlich (wie beim P83C).
Hardwarevalidierung — 2026-06-26 (Live-Drucker)
End-to-end gegen einen physischen A40 (PPG_A40_C2EA) von einem Linux-Laptop über Classic Bluetooth SPP/RFCOMM, Kanal 1 validiert — keine Hersteller-App, kein Android. Wir haben unseren eigenen Clean-Room-Roh-Raster-Stream gesendet und er hat gedruckt. Die Machbarkeit steht nicht mehr in Frage.
Geräte-Identität (aus unserem eigenen btsnoop-Mitschnitt gelesen)
Die Info-Abfrage der Hersteller-App (10 ff 70 / 10 ff 20 ee 54) liefert eine pipe-getrennte Zeichenkette zurück; unsere dekodierte zu:
PPG_A40_C2EA | 60:6E:41:45:C2:EA | C0:6E:41:45:C2:EA | V1.4.3_SD | A40233231104129 | 96 name classic MAC (LE-ish MAC) firmware serial battery%
-
Classic-SPP-Adresse
60:6E:41:45:C2:EA(RFCOMM ch.1); eine separate BLE-AdresseE0:6E:41:45:C2:EA(PPG_A40_C2EA_BLE) bewirbt sich ebenfalls — ignorieren, Classic SPP verwenden. -
Firmware
V1.4.3_SD(die dpi steht nicht in dieser Firmware-Zeichenkette, anders als bei älteren Einheiten).
⚠️ Die Hersteller-App verwendet ein KOMPRIMIERTES Raster — wir replizieren es bewusst NICHT
Beim Dekodieren unseres Mitschnitts (captures/btsnoop-.log, reassembliert tx.bin): der Bildbefehl der App ist *1f 00 00 <ce 09 17 00 00>, in 3 Bändern gesendet, und die Nutzlast ist ein eigener entropie-codierter / komprimierter Stream (~5,2× kleiner als roh; flache Autokorrelation, nahezu null Byte-Läufe, nicht zlib/gzip/deflate/packbits). Ihn zu knacken wäre eine Huffman-/Arithmetik-Decoder-Plackerei ohne Nutzen — weil:
Die A40-Firmware akzeptiert auch den einfachen rohen GS v 0-Befehl, dessen Druck wir bestätigt haben. Das ist unser Clean-Room-Ziel. Der komprimierte Pfad ist rein eine Bandbreiten-Optimierung des Herstellers; für einen gelegentlichen klinischen Protokolldruck ist roh (~513 KB/A4-Seite ≈ ~11 s über SPP) in Ordnung.
Was wir auf der Hardware bestätigt haben (roher GS v 0-Pfad)
| Eigenschaft | Ergebnis | |--|--| | Druckt rohes 1d 76 30 00 D8 00 yL yH <data>? | Ja (fw V1.4.3_SD) | | Polarität | Bit 1 = schwarz, 0xFF → durchgängig dunkel; keine Inversion nötig | | Dichte | Standardbrand ist grau; 10 ff 10 00 04 → durchgängig schwarz | | Breite | 216 Bytes = 1728 Punkte füllen das Papier von Kante zu Kante | | Ausrichtung | Bild Zeile → Vorschub (oben→unten), Byte/Spalte → Kopfbreite (links→rechts, byte0=linke Kante); keine Spiegelung — die Pixmap zeilenweise (row-major), MSB-first packen und verschicken | | Init / Vorschub | 10 ff fe 01+12×00 Init; 1b 4a <n> (ESC J) Vorschub funktioniert |
Minimaler funktionierender Stream (Clean-Room-Prototyp: ~/Projects/spikes/thermalprint/peripage_raw.py)
10 ff fe 01 00×12 # init/reset 10 ff 10 00 04 # darkness (level 4 = solid black) 1d 76 30 00 D8 00 yL yH # GS v 0: 216 B/row, yH..yL rows (LE) <rows × 216 B, 1-bpp MSB-first, bit1=black> 1b 4a 50 # feed
Über RFCOMM ch.1 schreiben (Python nativer AF_BLUETOOTH-Socket oder Android BluetoothSocket mit SPP-UUID 00001101-…), ~200 B Chunks. Keine Prüfsumme, keine Nonce, deterministisch.
USB-C-Direkttransport — ebenfalls VALIDIERT (ein zweiter, einfacherer Pfad)
An USB-C angesteckt enumeriert sich der A40 als Standard-USB-Gerät der Printer-Klasse und druckt denselben GS v 0-Stream — kein Bluetooth, keine Kopplung. Hardwarebestätigt (eine Testseite mit 3 Quadraten wurde über /dev/usb/lp0 gedruckt).
| USB-Deskriptor | Wert |
|---|---|
ID / Strings |
|
Interface |
Klasse 7 (Printer), Protokoll 2 (Bidirectional) |
Endpoints |
Bulk OUT |
Linux |
|
Android |
USB-Host (OTG) über |
Der Encoder ist transportagnostisch — identische Bytes gehen über BT oder USB hinaus. Empfehlung: Bluetooth = primär (Feld-Workflow: einen beliebigen Drucker in der Nähe wählen, kein Kabel); USB-C = Fallback (zuverlässiger, keine Kopplung/Reichweite/Batterieentladung — gut für eine feste Station oder wenn BT Zicken macht). Android-USB-Vorbehalte: das Tablet muss USB-Host sein (kann über denselben USB-C-Port nicht laden, während es hostet, ohne einen aktiv versorgten OTG-Hub) und es fesselt den Drucker per Kabel ans Tablet.
Verbleibend (nur Engineering — Machbarkeit erledigt)
-
Papierbreite = 210 mm (A4) bestätigt auf Hardware. Der 1728-Punkt-Kopf ≈216 mm, also werden ~6 mm vom Blatt abgeschnitten — Inhalt ≤1680 Punkte breit rendern + weiß auf 1728 auffüllen. A4-Höhe kalibriert: 2360 Zeilen = 297 mm (≈202 dpi; unser 2376-Zeilen-Test = 299 mm; das "A4" der Hersteller-App = 291 mm/kurz). Voller A4-Einzelauftrag mit 513 KB druckt ohne Stillstand (Risiko großer Aufträge beseitigt).
-
Rust
peripage-encoder= Portierung des obigen Prototyps (reine Funktion, fixture-getestet). -
tauri-plugin-peripage=BluetoothSocket-ch.1-Writer +BLUETOOTH_CONNECT-Berechtigung. -
Thermo-Mono-Typst-Variante + Dither-Option für Unterschriften (P83C-Lektionen tragen sich fort).
Workspace / Quell-Artefakte
-
Clean-Room-Prototyp + eigene Mitschnitte:
~/Projects/spikes/thermalprint/(außerhalb des proprietären Repos, wie es~/xlife-spike/für den P83C war). -
Nur Referenz (Code NICHT kopieren): die GPL-/unlizenzierten Repos in §Lizenzierung, für Protokoll-Fakten, die oben bereits transkribiert sind.