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 peripage-encoder-Crate und das tauri-plugin-peripage-Plugin sind gebaut und in osd integriert. src-tauri/src/commands/printer.rs rendert über render_protocol_luma / pack_luma und druckt über RFCOMM. Die Abschnitte „vorgeschlagene Implementierung", „offene Fragen" und „was ich von dir brauche" weiter unten bleiben als historischer Spike-Nachweis erhalten. Zur heutigen Einrichtung und Nutzung des Drucks siehe Drucken (PeriPage A40).

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?

bitrate16/peripage-python (PRIMÄRE Referenz)

A6/A6+/A40/A40+

GPL-3.0-or-later (LICENSE-Datei + Quell-Header pro Datei + license='GPLv3'; das license='MIT' in setup.py ist ein verirrter Template-String — die maßgeblichen Artefakte sind GPLv3)

NEIN — beim Coden nicht lesen

jmsiefer/peripage-printer

gleich (Fork des obigen)

GPL-3.0

NEIN

anthony-foulfoin/peripage-java

A40/A40+

Keine Lizenz = all rights reserved

NEIN (schlimmer als GPL — gar keine Rechteeinräumung)

hvfrancesco/cups-peripage

nur A6/A6+

GPL-3.0

entfällt (kein A40)

Dibyakshu/peripage-kotlin-bluetooth-printer

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

  1. Wer den Rust/Kotlin-Encoder schreibt, darf bitrate16/peripage-python, seinen Fork oder peripage-java nicht lesen. Als verbotene Quelle behandeln.

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

  3. 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).

  4. 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 A40(1728))

DPI

203 oder 304 dpi je nach Dichte-Variante ("300 dpi" bei Wiederverkäufern = gerundete 304)

Bestätigt (offiziell); der Wert unserer Einheit über 10 ff 30 10 / 10 ff 20 f1 festgenagelt

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 BluetoothSocket via device.createRfcommSocketToServiceRecord(UUID 00001101-0000-1000-8000-00805f9b34fb), dann socket.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 wie PeriPage_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 in tauri-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), dann 10 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:

  1. peripage-encoder — reines Rust-Crate ohne Android (Clean-Room, im separaten Workspace prototypisiert). Eingabe: Pixmap (oder &[u8] 1-bpp-Zeilen) + Modell-Enum. Ausgabe: der Vec<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.

  2. 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 wie WifiController). BLUETOOTH_CONNECT hinzufügen, über Device Owner automatisch gewährt.

  3. 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_png bei den pixel_per_pt des 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)

  1. Pixel-Polarität — brennt Bit 1 schwarz, oder invertieren wir? (Ein kleiner Mitschnitt beantwortet es.)

  2. Genaue dpi unserer Einheit — 10 ff 20 f1 → 203 vs. 304 → setzt pixel_per_pt.

  3. 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).

  4. Auftragsende-Schwanz — bestätigen, dass der A6-Schwanz 1b 4a 40 10 ff fe 45 auf A40 zutrifft.

  5. Wörtliche SPP-UUID vs. Kanal — RFCOMM ch.1 / …1101 auf 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:

  1. Das Telefon mit dem PeriPage A40 koppeln, ein Protokoll/Testbild aus der offiziellen PeriPage-App drucken.

  2. Das btsnoop-HCI-Log ziehen ("Bluetooth-HCI-Snoop-Log" in den Entwickleroptionen aktivieren → adb bugreport oder /sdcard/.../btsnoop_hci.log ziehen), damit wir den exakten Handshake + das GS v 0-Framing auf unserem Gerät erfassen.

  3. 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-Adresse E0: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

09c5:0200, iManufacturer "PeriPage Printer", iProduct "Printer"

Interface

Klasse 7 (Printer), Protokoll 2 (Bidirectional)

Endpoints

Bulk OUT 0x02 (Raster) · Bulk IN 0x81 (Status) · 64-Byte-Pakete

Linux

usblp bindet automatisch → /dev/usb/lp0; einfach den Stream `write()`en (validiert)

Android

USB-Host (OTG) über UsbManager + bulkTransfer() an EP 0x02; Status lesbar von 0x81

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)

  1. 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).

  2. Rust peripage-encoder = Portierung des obigen Prototyps (reine Funktion, fixture-getestet).

  3. tauri-plugin-peripage = BluetoothSocket-ch.1-Writer + BLUETOOTH_CONNECT-Berechtigung.

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