diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e339482..40e9742 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Firmware bauen +name: Build firmware on: push: @@ -12,20 +12,20 @@ jobs: - uses: arduino/setup-arduino-cli@v2 - - name: ESP32-Core und Bibliothek installieren + - name: Install ESP32 core and library run: | arduino-cli config init --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json arduino-cli core update-index arduino-cli core install esp32:esp32@3.3.12 arduino-cli lib install "GFX Library for Arduino@1.6.8" - - name: Firmware kompilieren + - name: Compile firmware run: > arduino-cli compile -b "esp32:esp32:esp32s3:USBMode=default,CDCOnBoot=cdc,FlashSize=16M,PartitionScheme=default_8MB,PSRAM=disabled,FlashMode=qio,CPUFreq=240" --export-binaries firmware/scanner - - name: Empfaenger und Pruefstand pruefen + - name: Check receiver and test bench run: | python3 -m py_compile empfaenger/scan-receiver.py bash -n test/stresstest.sh diff --git a/.gitignore b/.gitignore index 1711247..ab9c653 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,2 @@ -build/ -*.bin -*.elf -*.map -wifi.cfg -.DS_Store +__pycache__/ +firmware/scanner/build/ diff --git a/README.md b/README.md index f0e0c96..bba53b6 100644 --- a/README.md +++ b/README.md @@ -1,414 +1,410 @@ # Scan-Stick -Ein USB-Stick, der Scans selbst ins Netz weiterreicht. +A USB stick that passes scans on to the network by itself. -Ein LILYGO **T-Dongle-S3** steckt im USB-Anschluss eines Druckers und gibt sich als -gewöhnlicher USB-Speicher aus. Der Drucker scannt wie immer „an USB". Der Stick merkt, -wann eine Datei fertig geschrieben ist, gibt ihr einen Namen mit Zeitstempel, lädt sie -per HTTP an einen beliebigen Empfänger und räumt sie weg. Ein kleines Display zeigt, -was gerade passiert; eine Weboberfläche zeigt Zustand, Protokoll und die Dateien auf -der Karte. +A LILYGO **T-Dongle-S3** sits in the USB port of a printer and presents itself as an +ordinary USB storage device. The printer scans "to USB" as always. The stick notices +when a file has finished writing, gives it a name with a timestamp, uploads it over +HTTP to an arbitrary receiver and clears it away. A small display shows what is +happening right now; a web interface shows state, log and the files on the card. -Entstanden an einem **HP PageWide Color MFP 780**, im Prinzip aber an jedem Gerät -brauchbar, das „Scan an USB-Laufwerk" beherrscht. +Developed on an **HP PageWide Color MFP 780**, but in principle usable on any device +that can do "scan to USB drive". -## Warum nicht einfach „Scan in Netzwerkordner"? +## Why not just "scan to network folder"? -Weil der Drucker dabei bei **jedem** Scan erst prüft, ob er sein Ablageziel erreicht — -das kostet spürbar Zeit, bevor überhaupt Papier eingezogen wird. Gegenüber dem Stick -ist das Laufwerk sofort da; der Netzwerkteil passiert erst danach und stört den -Bedienablauf nicht mehr. Außerdem bleibt das Ziel austauschbar: Der Stick kennt nur -eine URL, alles Weitere macht der Empfänger. +Because the printer then checks on **every** scan whether it can reach its destination - +that costs noticeable time before any paper is even pulled in. Compared to the stick, +the drive is there immediately; the network part only happens afterwards and no longer +disturbs the workflow. The destination also stays interchangeable: the stick only knows +a URL, the receiver does everything else. -## Ablauf +## Flow ``` -Drucker schreibt Sektoren → Stick erkennt Schreibzugriffe +Printer writes sectors → Stick detects write accesses ↓ - liest das Dateiverzeichnis roh mit (stört den Drucker nicht) + reads the directory raw alongside (does not disturb the printer) ↓ - Datei vollständig? (%%EOF vorhanden, 3 s Ruhe) + file complete? (%%EOF present, 3 s of quiet) ↓ - liest die Datei roh entlang der Belegungskette und lädt sie hoch - — der Drucker hat das Medium die ganze Zeit + reads the file raw along the allocation chain and uploads it + - the printer has the medium the whole time ↓ - merkt sich im Flash: gesendet (Startcluster, Größe, Kennzahl) + records in flash: sent (start cluster, size, checksum) ↓ - später, wenn der Drucker n Minuten nichts angefasst hat: - Medium für unter eine Sekunde weg, Gesendetes löschen, - Geister austragen, Karte prüfen und heilen + later, once the printer has not touched anything for n minutes: + medium gone for less than a second, delete what was sent, + remove ghosts, check and heal the card ``` -**Im Betrieb wird das Medium nie abgemeldet.** Der Stick fasst weder den Dateisystem-Treiber -noch die Karte an, solange gescannt wird: Er liest Verzeichnis, Belegungstabelle und -Datenblöcke direkt aus den Sektoren. Umbenennen ist unnötig. Was gesendet ist, steht in einer -Merkliste im Flash (Startblock, Größe, Kennzahl) und wird nicht erneut gesendet. Liegt die -gesendete Datei noch da, fragt der 780 beim nächsten Scan „Datei bereits vorhanden" — -„Ersetzen" ist richtig, die neue Fassung gilt als neu. - -Aufgeräumt wird nur in einer Ruhephase: nach einer einstellbaren Zeit ohne jeden Zugriff des -Druckers (Vorgabe 3 Minuten — der 780 fasst den Stick zwischen Jobs nicht an), wenn die Merkliste voll wird, oder auf Knopfdruck in der -Weboberfläche. Das ist der einzige Moment, in dem das Medium kurz weg ist, und dann steht -niemand am Gerät. Frühere Fassungen nahmen dem Drucker das Medium bei jedem Scan weg, zuerst -für die Dauer des Uploads, zuletzt für eine halbe Sekunde; jeder Job, der genau dann startete -oder während der Drucker den Stick neu einhängte, scheiterte. - -Die Karte sollte **klein partitioniert** sein, etwa 4 GB mit 32-kB-Clustern: Nach jedem -Aufräumen hängt der Drucker den Stick neu ein und liest dabei die Belegungstabelle. Bei -64 GB sind das 60 MB über USB, bei 4 GB mit großen Clustern 512 kB. +**During operation the medium is never unmounted.** The stick touches neither the file system +driver nor the card while scanning is going on: it reads directory, allocation table and +data blocks directly from the sectors. Renaming is unnecessary. What has been sent is kept in a +list in flash (start block, size, checksum) and is not sent again. If the sent file is still +there, the 780 asks "file already exists" on the next scan - "replace" is the right answer, the +new version counts as new. + +Cleanup only happens in a quiet phase: after a configurable time without any access by the +printer (default 3 minutes - the 780 does not touch the stick between jobs), when the sent list +fills up, or at the push of a button in the web interface. That is the only moment in which the +medium is briefly gone, and then nobody is standing at the device. Earlier versions took the +medium away from the printer on every scan, at first for the duration of the upload, later for +half a second; every job that started exactly then, or while the printer was remounting the +stick, failed. + +The card should be **partitioned small**, about 4 GB with 32 kB clusters: after every +cleanup the printer remounts the stick and reads the allocation table while doing so. With +64 GB that is 60 MB over USB, with 4 GB and large clusters 512 kB. ## Hardware -**LILYGO T-Dongle-S3** (ESP32-S3, 16 MB Flash, microSD im USB-A-Stecker, ST7735-Display, -APA102-LED). Eine microSD mit **FAT32** gehört hinein — 64 GB funktionieren, entgegen -mancher Behauptung; sie müssen nur FAT32 statt exFAT sein. +**LILYGO T-Dongle-S3** (ESP32-S3, 16 MB flash, microSD in the USB-A plug, ST7735 display, +APA102 LED). A microSD with **FAT32** belongs in it - 64 GB does work, contrary to some +claims; it only has to be FAT32 instead of exFAT. -| Funktion | Pins | +| Function | Pins | |---|---| -| SD (SD_MMC, 4 Bit) | CLK 12, CMD 16, D0 14, D1 17, D2 21, D3 18 | -| Display ST7735 | CS 4, SDA 3, SCL 5, DC 2, RST 1, Licht 38 (aktiv low) | -| Status-LED APA102 | Daten 40, Takt 39, BGR | +| SD (SD_MMC, 4 bit) | CLK 12, CMD 16, D0 14, D1 17, D2 21, D3 18 | +| Display ST7735 | CS 4, SDA 3, SCL 5, DC 2, RST 1, backlight 38 (active low) | +| Status LED APA102 | data 40, clock 39, BGR | ## Installation -### 1. Karte vorbereiten +### 1. Prepare the card -Eine microSD mit **FAT32**. Karten bis 32 GB sind ab Werk meist schon so formatiert und -können direkt hinein. Größere kommen als exFAT, das der Stick nicht lesen kann — sie -müssen einmalig auf FAT32 umformatiert werden, **in einem richtigen Kartenleser**: +A microSD with **FAT32**. Cards up to 32 GB usually come formatted that way from the factory +and can go straight in. Larger ones arrive as exFAT, which the stick cannot read - they have +to be reformatted to FAT32 once, **in a real card reader**: ```bash -# Linux/macOS, /dev/sdX durch das tatsächliche Gerät ersetzen – Vorsicht, löscht alles +# Linux/macOS, replace /dev/sdX with the actual device - careful, erases everything sudo parted -s /dev/sdX mklabel msdos -sudo parted -s /dev/sdX mkpart primary fat32 1MiB 4097MiB # 4 GB reichen, siehe Ablauf +sudo parted -s /dev/sdX mkpart primary fat32 1MiB 4097MiB # 4 GB is enough, see Flow sudo parted -s /dev/sdX set 1 lba on -sudo mkfs.vfat -F 32 -s 64 -n SCANS /dev/sdX1 # 32-kB-Cluster: kleine Belegungstabelle +sudo mkfs.vfat -F 32 -s 64 -n SCANS /dev/sdX1 # 32 kB clusters: small allocation table ``` -Unter Windows tut es ein Werkzeug wie „FAT32 Format", die Bordmittel bieten FAT32 -oberhalb von 32 GB nicht an. Eine kleine Partition lässt sich auch über den eingesteckten -Stick anlegen (4 GB mit großen Clustern in vier Sekunden); eine 64-GB-Partition dagegen -nicht, das Formatieren schreibt dann minutenlang und der Stick bricht ab. +On Windows a tool such as "FAT32 Format" does the job, the built-in tools do not offer FAT32 +above 32 GB. A small partition can also be created through the plugged-in stick (4 GB with +large clusters in four seconds); a 64 GB partition cannot, formatting then writes for minutes +and the stick aborts. -### 2. Firmware aufspielen +### 2. Flash the firmware -Einmalig über Kabel, siehe [Bauen und Flashen](#bauen-und-flashen). Fertige Abbilder -liegen unter [Releases](../../releases). +Once over cable, see [Building and flashing](#building-and-flashing). Ready-made images +are available under [Releases](../../releases). -### 3. WLAN-Zugangsdaten eintragen +### 3. Enter the WiFi credentials -**Der einfache Weg: über das eigene WLAN des Sticks.** Kennt der Stick kein Netz oder -erreicht er keins, spannt er ein eigenes WLAN namens `scanstick-XXXX` auf (offen; XXXX steht -auch im Display). Handy oder Rechner damit verbinden — die Einrichtungsseite öffnet sich von -selbst, wie bei einem Hotel-WLAN, sonst `http://192.168.4.1/` aufrufen. Dort Netz aus der -Liste wählen, Passwort, Empfängeradresse, optional Geräteschlüssel und Web-Passwort eintragen, -speichern. Der Stick startet neu und ist danach im Heimnetz unter `http://scanstick-XXXX.local/` -erreichbar. Weitere Netze lassen sich später in den Einstellungen hinzufügen oder entfernen. +**The easy way: through the stick's own WiFi.** If the stick knows no network, or cannot +reach one, it opens its own WiFi named `scanstick-XXXX` (open; XXXX is also shown on the +display). Connect a phone or computer to it - the setup page opens by itself, as with a hotel +WiFi, otherwise open `http://192.168.4.1/`. There, pick a network from the list, enter +password, receiver address, optionally device key and web password, and save. The stick +restarts and is then reachable on the home network at `http://scanstick-XXXX.local/`. +Further networks can be added or removed later in the Settings. -**Der zweite Weg: eine Datei auf der Karte.** Auch dafür ist **kein Kartenleser nötig** — der -Stick ist ja selbst ein USB-Laufwerk: +**The second way: a file on the card.** For this too **no card reader is needed** - the +stick is itself a USB drive: -1. Stick in den Computer stecken, es erscheint ein Laufwerk namens **SCANS** -2. darauf eine Textdatei **`wifi.cfg`** anlegen (Vorlage: `wifi.cfg.beispiel`): +1. plug the stick into the computer, a drive named **SCANS** appears +2. create a text file **`wifi.cfg`** on it (template: `wifi.cfg.example`): ``` -ssid=MeinWLAN -pass=MeinPasswort +ssid=MyWiFi +pass=MyPassword endpoint=http://192.168.1.50:8080/scan ``` -Mehrere Netze sind erlaubt, bis zu vier: Jede `ssid=`-Zeile beginnt ein neues Netz, das -folgende `pass=` gehört dazu. Beim Suchlauf gewinnt über alle bekannten Netze hinweg der -stärkste Zugangspunkt, so läuft derselbe Stick an mehreren Standorten oder am Hotspot. +(The keys are German by design: they are the file-format contract and stay as they are.) + +Several networks are allowed, up to four: every `ssid=` line starts a new network, the +`pass=` that follows belongs to it. During the scan the strongest access point across all +known networks wins, so the same stick works at several locations or on a hotspot. -**Nur 2,4 GHz.** Der ESP32 sieht keine 5-GHz-Netze. Ein iPhone-Hotspot sendet standardmäßig -auf 5 GHz und bleibt für den Stick unsichtbar, ohne jede Fehlermeldung; erst mit -*Kompatibilität maximieren* in den Hotspot-Einstellungen wechselt er auf 2,4 GHz. Bei -Dual-Band-Routern muss das Netz ebenfalls auf 2,4 GHz sichtbar sein. +**2.4 GHz only.** The ESP32 does not see 5 GHz networks. An iPhone hotspot transmits on +5 GHz by default and stays invisible to the stick, without any error message; only with +*Maximize Compatibility* in the hotspot settings does it switch to 2.4 GHz. With dual-band +routers the network must likewise be visible on 2.4 GHz. ``` -ssid=Buero -pass=geheim1 -ssid=Zuhause -pass=geheim2 +ssid=Office +pass=secret1 +ssid=Home +pass=secret2 endpoint=http://192.168.1.50:8080/scan ``` -3. Laufwerk auswerfen, Stick abziehen +3. eject the drive, unplug the stick -Beim nächsten Start liest er die Datei und **spiegelt die Zugangsdaten in seinen -Flash-Speicher**. Danach kommen WLAN und Weboberfläche auch dann hoch, wenn die Karte -fehlt oder unlesbar ist — sonst hätte man genau dann keine Diagnose, wenn man sie -braucht. Die Datei bleibt liegen. Übernommen wird sie nur, wenn sich ihr Inhalt seit dem -letzten Mal **geändert** hat; sonst gelten die Werte aus dem Flash, also auch alles, was in -der Weboberfläche eingestellt wurde. Zum Ändern des Netzes genügt es, sie zu überschreiben. +On the next start it reads the file and **mirrors the credentials into its flash +memory**. After that WiFi and web interface come up even when the card is missing or +unreadable - otherwise there would be no diagnostics exactly when they are needed. The +file stays where it is. It is only adopted if its content has **changed** since last +time; otherwise the values from flash apply, including everything that was set in the +web interface. To change the network it is enough to overwrite it. -### 4. In den Drucker +### 4. Into the printer -Stick in den USB-Anschluss, etwa zehn Sekunden warten, bis das Display **BEREIT** zeigt, -dann am Gerät „Scan an USB-Laufwerk" wählen. Die Adresse der Weboberfläche steht im -Protokoll und wird beim Start angezeigt. +Plug the stick into the USB port, wait about ten seconds until the display shows **READY**, +then choose "scan to USB drive" at the device. The address of the web interface is in the +log and is shown at startup. -## Bauen und Flashen +## Building and flashing ```bash arduino-cli compile -b "esp32:esp32:esp32s3:USBMode=default,CDCOnBoot=cdc,FlashSize=16M,PartitionScheme=default_8MB,PSRAM=disabled,FlashMode=qio,CPUFreq=240" --export-binaries firmware/scanner ``` -`default_8MB` ist wichtig: zwei Programmbereiche, damit Aktualisierungen über WLAN -möglich sind. Mit `app3M_fat9M_16MB` gäbe es nur einen und jede abgebrochene -Übertragung wäre fatal. +`default_8MB` matters: two program areas, so that updates over WiFi are possible. With +`app3M_fat9M_16MB` there would be only one and every aborted transfer would be fatal. -Flashen über Kabel: +Flashing over cable: ```bash -# 1. Die laufende Firmware in den Flash-Modus holen (Port kurz mit 1200 Baud öffnen) +# 1. Put the running firmware into flash mode (briefly open the port at 1200 baud) python3 -c 'import serial,time; s=serial.Serial("/dev/ttyACM0",1200); s.dtr=False; time.sleep(0.3); s.close()' sleep 4 -# 2. Vollständig schreiben +# 2. Write completely esptool --chip esp32s3 --port /dev/ttyACM0 --no-stub --before default_reset --after no_reset \ write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB \ 0x0 firmware/scanner/build/*/scanner.ino.bootloader.bin \ 0x8000 firmware/scanner/build/*/scanner.ino.partitions.bin \ 0xe000 firmware/scanner/build/*/boot_app0.bin \ 0x10000 firmware/scanner/build/*/scanner.ino.bin -# 3. Stick abziehen, 5 s warten, wieder einstecken +# 3. Unplug the stick, wait 5 s, plug it in again ``` -Danach geht es bequemer: Weboberfläche → **Firmware** → `scanner.ino.bin` hochladen. - -## Fallen, die uns Stunden gekostet haben - -**Nach dem Flashen hilft nur echtes Abziehen.** Der ESP32-S3 bleibt sonst im Flash-Modus -stecken: kein Laufwerk, keine serielle Ausgabe, keine Firmware. Weder ein Software-Reset -noch das Abschalten der Anschlussspannung per `uhubctl` holen ihn heraus — auch nicht -nach zwanzig Sekunden. Nur physisch ausstecken und wieder einstecken. -Test auf diesen Zustand: `esptool --before no_reset read_mac` — **verbindet** er sich, -läuft **keine** Firmware. - -**Serielle Ausgaben gibt es im Betrieb nicht.** Die Konsole gehört im -Massenspeicher-Betrieb dem USB-Stack. Deshalb protokolliert der Stick in den -Arbeitsspeicher und zeigt es über die Weboberfläche. - -**Dem Drucker das Medium zu entziehen, während er schreibt, zerstört den Scan.** -Die Cluster sind dann belegt, der Verzeichniseintrag fehlt — die Datei existiert nie. -Erst **nachdem** eine fertige Datei gefunden wurde, ist das Abmelden gefahrlos. - -**Umgekehrt darf man die Karte nicht ändern, während der Drucker sie sieht.** -Er hält eine eigene, gepufferte Sicht auf das Verzeichnis und schreibt sie später -zurück — Umbenennungen und ganze Ordner verschwinden dadurch wieder. - -**Der Drucker schreibt seine alte Sicht sogar nach dem Wiederanmelden zurück.** Beim -nächsten Scan taucht der vorige Scan als „Geist" erneut in der Wurzel auf: ein -Verzeichniseintrag, der auf die Blöcke der schon gesendeten Datei zeigt. Ihn per -Dateisystem zu löschen gäbe diese Blöcke frei, also die Daten der anderen Datei; ihn zu -verschieben hinterließe zwei Einträge auf denselben Blöcken (Kreuzverkettung). Der Stick -lädt ihn deshalb hoch, der Empfänger erkennt ihn an der Kennung als Duplikat, und der -Stick trägt daraufhin nur den Verzeichniseintrag roh aus, ohne die Blockzuordnung -anzufassen. Ein eigener Empfänger muss das Wort `Duplikat` in seiner Antwort tragen. - -**Die Dateigröße ist kein Zeichen für „fertig".** Der 780 trägt die endgültige Größe -ein, *bevor* er schreibt, und reserviert den Platz. Wer darauf vertraut, lädt eine -halb beschriebene Datei hoch, deren hinterer Teil aus Leerbytes besteht. Deshalb prüft -der Stick die Endmarke `%%EOF`. - -**Das Verzeichnis zum Nachsehen ab- und wieder anzuhängen ist gefährlich.** Läuft das -parallel zu einem Lesezugriff des Druckers, greift der USB-Teil auf einen abgeräumten -Kartentreiber zu und das Gerät startet neu. Ein Blick auf die Weboberfläche darf den -Betrieb nie gefährden — deshalb liest der Stick das Verzeichnis roh mit. - -**„Medium abmelden" stoppt keinen laufenden Zugriff.** `mediaPresent(false)` weist nur -*neue* Kommandos ab. Ein Lesevorgang, der gerade läuft, läuft weiter — ein Linux-Host -liest beim Aushängen die FAT in 120-kB-Blöcken, das dauert länger als jede feste -Wartezeit. Wird der Kartentreiber in dieser Zeit abgebaut, hängt der USB-Teil, und nach -fünf Sekunden startet der Task-Watchdog das Gerät neu (im Kernel-Log des Hosts steht dann -`cmd_age=5s`). Der Stick zählt deshalb laufende Zugriffe mit und fasst die Karte erst an, -wenn keiner mehr offen ist. Aus demselben Grund gilt als „Ruhe" erst, wenn der Host weder -schreibt **noch liest**. - -**Ein Neustart am Drucker ist ein Stromschnitt.** Meldet sich der Stick per USB ab, schaltet -der 780 dem Port kurz die Versorgung weg. Nach einem Firmware-Update über die -Weboberfläche kam die neue Firmware dadurch nie dazu, sich als gültig zu bestätigen — der -Bootloader fiel zweimal auf die alte zurück. Der Stick meldet deshalb erst das Medium ab, -trennt USB, wartet den Stromschnitt ab und startet erst dann neu (`sanftNeustarten`). -Auf der Statusseite steht danach „Strom eingeschaltet" als Startgrund — das ist normal. - -**Den Dialog „Einstellungen für den nächsten Job beibehalten?" erst beantworten, wenn der -Stick fertig ist.** Der 780 schreibt große Scans noch lange, nachdem das Papier durch ist, -und zeigt dabei schon diesen Dialog. Wer ihn mit Löschen oder Abbrechen beantwortet, während -der Drucker noch schreibt, bricht den eigenen Job ab: Ereignis 44.12.05 „Error writing -multi-page image file". Danach nimmt der 780 dem USB-Port kurz den Strom, und sein USB-Host -liest **gar nichts mehr** — auch keinen gewöhnlichen USB-Stick — bis der Drucker neu -gestartet wird. Umstecken hilft nicht. Solange das Display des Sticks „SCAN ERKANNT" oder -den Countdown zeigt, schreibt der Drucker noch; erst bei „SENDET" oder „BEREIT" ist er durch. - -**Der 780 überschreibt eine gleichnamige Datei.** Bleibt `[Untitled].pdf` nach dem Senden -auf der Karte liegen, hängt er beim nächsten Scan keinen Zeitstempel an, sondern schreibt -dieselbe Datei neu. Der Stick erkennt eine Datei deshalb nicht am Namen, sondern an -Startblock, Größe und Kennzahl — die neue Fassung gilt damit als neu und wird gesendet. - -**Nicht jeder Schreibzugriff ist ein Scan.** Beim Öffnen des Scan-Dialogs schreibt der -780 eine Testdatei von einem Block und löscht sie gleich wieder (rund 4 kB: FAT, Wurzel, -ein Datenblock, wieder Wurzel und FAT). Die Anzeige zählt deshalb nur, was noch nicht -gesendet ist; die Statusseite zeigt die letzten Schreibzugriffe des Hosts nach Bereich. - -**Der Drucker lehnt eine beschädigte Karte ab.** Nach abgebrochenen Jobs und -zurückgeschriebenen Verzeichnissen blieben Kreuzverkettungen, verwaiste Blöcke und die -Schmutzmarke auf der Karte — und der 780 zeigte nur noch „USB-Stick anschließen". Der -Stick prüft die Karte deshalb selbst, beim Start (bevor der Drucker sie sieht) und im -Aufräumfenster: beide Zuordnungstabellen abgleichen, jede Kette ablaufen, doppelt belegte -Blöcke dem zuerst gefundenen Eintrag lassen und den zweiten austragen, Waisen freigeben, zu -kurze Ketten kürzen, Schmutzmarke setzen. Ist das Dateisystem zweimal in Folge unlesbar, -legt er die Partition neu an (`POST /formatieren` tut das auch auf Knopfdruck, nur wenn -nichts Ungesendetes liegt). Das Ergebnis steht auf der Statusseite unter „Kartenprüfung". -Der Prüfstand hat dafür Szenario E. - -**Nach dem Senden wird gelöscht, nicht verschoben.** Ein nach `/gesendet` verschobener -Eintrag behält seine Blöcke. Schreibt der Drucker danach seinen alten Wurzeleintrag zurück -und ersetzt ihn, gibt er genau diese Blöcke frei — Kreuzverkettung. Gelöschte Blöcke sind -frei, da kann ein Geist nichts mehr anrichten. Die Option „nach /gesendet verschieben" -gibt es deshalb seit v37 nicht mehr; die Kopie liegt beim Empfänger. - -**Bei mehreren Zugangspunkten mit derselben Kennung** nimmt `WiFi.begin(ssid, pass)` -den erstbesten, nicht den stärksten. Der Stick sucht deshalb vorher (`WiFiMulti`) und -verbindet sich gezielt mit der besten Station. Die Kehrseite: Fällt genau diese Station -aus, versucht der automatische Wiederverbinder nur sie. Nach zwei Minuten ohne Netz sucht -der Stick deshalb von vorn, über alle bekannten Netze. - -## Weboberfläche - -Erreichbar über `http://scanstick-XXXX.local/` (XXXX = die letzten vier Stellen der -Funkadresse, steht im Protokoll und auf der Statusseite) oder die angezeigte IP-Adresse. -So kommen sich mehrere Sticks im selben Netz nicht in die Quere. - -| Seite | Inhalt | +After that it is more convenient: web interface -> **Firmware** -> upload `scanner.ino.bin`. + +## Pitfalls that cost us hours + +**After flashing, only a real unplug helps.** Otherwise the ESP32-S3 stays stuck in flash +mode: no drive, no serial output, no firmware. Neither a software reset nor switching off +the port power with `uhubctl` gets it out - not even after twenty seconds. Only physically +unplugging and plugging it in again. +Test for this state: `esptool --before no_reset read_mac` - if it **connects**, **no** +firmware is running. + +**There is no serial output during operation.** In mass storage mode the console belongs +to the USB stack. That is why the stick logs into memory and shows it through the web +interface. + +**Taking the medium away from the printer while it is writing destroys the scan.** +The clusters are then allocated, the directory entry is missing - the file never exists. +Only **after** a complete file has been found is unmounting safe. + +**Conversely, the card must not be changed while the printer sees it.** +It keeps its own buffered view of the directory and writes it back later - renames and +whole folders disappear again as a result. + +**The printer even writes its old view back after remounting.** On the next scan the +previous scan shows up in the root again as a "ghost": a directory entry that points at +the blocks of the file that was already sent. Deleting it through the file system would +free those blocks, that is the data of the other file; moving it would leave two entries +on the same blocks (cross-linking). The stick therefore uploads it, the receiver +recognizes it as a duplicate by its identifier, and the stick then only removes the +directory entry raw, without touching the block allocation. A custom receiver must carry +the word `Duplikat` in its reply. + +**The file size is no sign of "finished".** The 780 writes the final size *before* it +writes, and reserves the space. Whoever relies on it uploads a half-written file whose +tail consists of empty bytes. That is why the stick checks for the end marker `%%EOF`. + +**Unmounting and remounting the directory just to look is dangerous.** If that runs +in parallel with a read access of the printer, the USB part accesses a torn-down card +driver and the device restarts. A look at the web interface must never endanger +operation - that is why the stick reads the directory raw alongside. + +**"Unmount medium" does not stop an access in progress.** `mediaPresent(false)` only +rejects *new* commands. A read that is currently running keeps running - a Linux host +reads the FAT in 120 kB blocks when unmounting, which takes longer than any fixed wait +time. If the card driver is torn down during that time, the USB part hangs, and after +five seconds the task watchdog restarts the device (the host's kernel log then shows +`cmd_age=5s`). The stick therefore counts accesses in progress and only touches the card +once none is open any more. For the same reason "quiet" only applies once the host is +neither writing **nor reading**. + +**A restart at the printer is a power cut.** When the stick deregisters over USB, the 780 +briefly cuts the power to the port. After a firmware update through the web interface the +new firmware therefore never got around to confirming itself as valid - the bootloader +fell back to the old one twice. The stick therefore first unmounts the medium, disconnects +USB, waits out the power cut and only then restarts (`sanftNeustarten`). The status page +afterwards shows "Power on" as the reset reason - that is normal. + +**Only answer the dialog "Keep settings for the next job?" once the stick is done.** +The 780 keeps writing large scans long after the paper has gone through, and already shows +this dialog while doing so. Answering it with delete or cancel while the printer is still +writing aborts your own job: event 44.12.05 "Error writing multi-page image file". +After that the 780 briefly cuts the power to the USB port, and its USB host reads +**nothing at all** any more - not even an ordinary USB stick - until the printer is +restarted. Replugging does not help. As long as the stick's display shows "SCAN FOUND" +or the countdown, the printer is still writing; only at "SENDING" or "READY" is it done. + +**The 780 overwrites a file of the same name.** If `[Untitled].pdf` stays on the card +after sending, the printer does not append a timestamp on the next scan but writes the +same file anew. The stick therefore recognizes a file not by its name but by start block, +size and checksum - so the new version counts as new and is sent. + +**Not every write access is a scan.** When the scan dialog is opened, the 780 writes a +test file of one block and deletes it again right away (about 4 kB: FAT, root, one data +block, root and FAT again). The display therefore only counts what has not been sent yet; +the status page shows the host's last write accesses by area. + +**The printer rejects a damaged card.** After aborted jobs and written-back directories, +cross-links, orphaned blocks and the dirty flag remained on the card - and the 780 only +showed "connect USB stick" any more. The stick therefore checks the card itself, at startup +(before the printer sees it) and in the cleanup window: compare both allocation tables, walk +every chain, leave doubly allocated blocks to the entry found first and remove the second, +free orphans, shorten chains that are too short, clear the dirty flag. If the file system is +unreadable twice in a row, it recreates the partition (`POST /formatieren` does that at the +push of a button too, only when nothing unsent is present). The result is shown on the status +page under "Card check". The test bench has scenario E for this. + +**After sending, files are deleted, not moved.** An entry moved to `/gesendet` keeps its +blocks. If the printer then writes its old root entry back and replaces it, it frees exactly +those blocks - cross-linking. Deleted blocks are free, a ghost can do no more harm there. +That is why the option "move to /gesendet" no longer exists since v37; the copy is at the +receiver. + +**With several access points using the same SSID**, `WiFi.begin(ssid, pass)` takes the +first one it finds, not the strongest. The stick therefore scans beforehand (`WiFiMulti`) +and connects to the best station on purpose. The downside: if exactly that station fails, +the automatic reconnector only tries that one. After two minutes without a network the +stick therefore scans again from scratch, across all known networks. + +## Web interface + +Reachable at `http://scanstick-XXXX.local/` (XXXX = the last four digits of the MAC +address, shown in the log and on the status page) or the displayed IP address. That way +several sticks on the same network do not get in each other's way. + +| Page | Content | |---|---| -| Status | Karte, Empfang samt gewähltem Zugangspunkt, offene Schreibvorgänge, Uhrzeit, Laufzeit | -| Protokoll | vollständiger Ablauf seit dem Start | -| Dateien | Wurzel roh gelesen mit Stand (gesendet, offen, Geist), Ordner, einzeln herunterladbar | -| Roh | was der Stick ohne Dateisystem-Treiber sieht (Diagnose) | -| Einstellungen | siehe unten | -| Firmware | Aktualisierung über WLAN | - -Ein **Passwort** lässt sich setzen (Benutzername `scan`). Ohne kann jedes Gerät im -selben Netz die Scans herunterladen. - -Alles, was etwas verändert (Einstellungen, „Jetzt schauen", Neustart, Firmware), geht -nur per `POST` und nur von der eigenen Seite: Schickt ein Browser eine `Origin`- oder -`Referer`-Kopfzeile mit, muss sie zum Stick passen. Sonst könnte eine beliebige fremde -Webseite im selben Browser das Upload-Ziel umbiegen oder den Stick mitten im Upload neu -starten, denn ein gespeichertes Passwort schickt der Browser automatisch mit. Werkzeuge -wie `curl` schicken keine `Origin`-Kopfzeile und funktionieren weiter: +| Status | card, reception including the chosen access point, open writes, time, uptime | +| Log | complete sequence since startup | +| Files | root read raw with state (sent, open, ghost), folders, individually downloadable | +| Raw | what the stick sees without a file system driver (diagnostics) | +| Settings | see below | +| Firmware | update over WiFi | + +A **password** can be set (user name `scan`). Without one, every device on the same +network can download the scans. + +Everything that changes something (settings, "Check now", restart, firmware) goes only +by `POST` and only from the stick's own page: if a browser sends an `Origin` or +`Referer` header, it has to match the stick. Otherwise any foreign web page in the same +browser could redirect the upload target or restart the stick in the middle of an upload, +because the browser sends a stored password automatically. Tools like `curl` send no +`Origin` header and keep working: ```bash -curl -u scan:PASSWORT --data-urlencode 'endpoint=http://192.168.1.50:8080/scan' http://scanstick-1a2b.local/einstellungen -curl -u scan:PASSWORT -X POST http://scanstick-1a2b.local/neustart +curl -u scan:PASSWORD --data-urlencode 'endpoint=http://192.168.1.50:8080/scan' http://scanstick-1a2b.local/einstellungen +curl -u scan:PASSWORD -X POST http://scanstick-1a2b.local/neustart ``` -## Einstellungen +## Settings -Upload-Ziel · Namensanfang der Dateien (z. B. Standortkennung) · Ruhefrist · Aufräumen nach n Minuten Ruhe · -nach dem Senden löschen oder nach `/gesendet` verschieben · Passwort · -Geräteschlüssel für den Upload · Helligkeit der Status-LED (0 = aus) · -Farbumkehr des Displays · acht Zustandsfarben für Display und LED +Upload target - file name prefix (e.g. a site code) - quiet period - cleanup after n minutes of quiet - +delete after sending or move to `/gesendet` - password - +device key for the upload - brightness of the status LED (0 = off) - +color inversion of the display - eight state colors for display and LED -Der **Geräteschlüssel** signiert jeden Upload (HMAC-SHA256 über Name, Kennung und Länge, -Kopfzeile `X-Scan-Auth`). Derselbe Schlüssel gehört in den Empfänger (`SCAN_KEY`), der dann -alles ohne gültige Signatur abweist. Er lässt sich auch in `wifi.cfg` als `schluessel=` -hinterlegen. Bewusst kein TLS: Im LAN reicht das, ein TLS-Kontext kostet auf dem Stick rund -40 kB Arbeitsspeicher und jeden Upload spürbar Zeit. Wiederholungen brauchen keinen -Zeitstempel, der Empfänger erkennt sie an der Kennung. +The **device key** signs every upload (HMAC-SHA256 over name, identifier and length, +header `X-Scan-Auth`). The same key belongs in the receiver (`SCAN_KEY`), which then +rejects everything without a valid signature. It can also be stored in `wifi.cfg` as +`schluessel=`. Deliberately no TLS: on the LAN that is enough, a TLS context costs about +40 kB of memory on the stick and noticeable time per upload. Repeats need no timestamp, +the receiver recognizes them by the identifier. -Liegt eine Datei nach einem Fehlversuch noch auf der Karte (Empfänger nicht erreichbar, -WLAN weg), sieht der Stick **alle zehn Minuten** roh nach und versucht es erneut. +If a file is still on the card after a failed attempt (receiver unreachable, WiFi gone), +the stick looks again raw **every ten minutes** and retries. -## Empfänger +## Receiver -`empfaenger/scan-receiver.py` ist ein Beispiel in reinem Python ohne Abhängigkeiten: -nimmt `POST /scan?name=…` entgegen und legt die Datei ab. +`empfaenger/scan-receiver.py` is an example in pure Python without dependencies: +it accepts `POST /scan?name=...` and stores the file. ```bash -python3 empfaenger/scan-receiver.py # lauscht auf Port 8080, legt in ~/scan-inbox ab -SCAN_PORT=9000 SCAN_INBOX=/srv/scans SCAN_KEY=geheim python3 empfaenger/scan-receiver.py +python3 empfaenger/scan-receiver.py # listens on port 8080, stores in ~/scan-inbox +SCAN_PORT=9000 SCAN_INBOX=/srv/scans SCAN_KEY=secret python3 empfaenger/scan-receiver.py ``` -Mit `SCAN_KEY` verlangt er zu jedem Upload die Signatur des Sticks (siehe Einstellungen) -und antwortet sonst mit 401. +With `SCAN_KEY` it requires the stick's signature for every upload (see Settings) and +otherwise answers with 401. -Der Empfänger nimmt eine Datei nur an, wenn sie **vollständig** ist: Die Länge muss der -Ankündigung entsprechen, eine PDF muss auf `%%EOF` enden. Sonst antwortet er mit 400 -und merkt sich nichts, der Stick versucht es später erneut. Das ist wichtig, weil der -Stick Wiederholungen über eine Kennung meldet und der Empfänger sie verwirft: Würde -er einen abgerissenen Upload verkürzt ablegen und die Kennung merken, gälte die -Wiederholung als Duplikat, und der Stick löschte daraufhin das einzige vollständige -Exemplar. Das Protokoll des Sticks (`scanlog-*.txt`) landet im Unterordner `protokoll/`. +The receiver only accepts a file if it is **complete**: the length has to match the +announcement, a PDF has to end with `%%EOF`. Otherwise it answers with 400 and +remembers nothing, and the stick retries later. That matters because the stick reports +repeats via an identifier and the receiver discards them: if it stored a broken-off +upload truncated and remembered the identifier, the repeat would count as a duplicate, +and the stick would then delete the only complete copy. The stick's log +(`scanlog-*.txt`) ends up in the subfolder `protokoll/`. -Für den Produktivbetrieb tritt hier etwas anderes an die Stelle — Ablage in einer -Cloud, einem Dokumentensystem oder einem Ordner. Die Firmware kennt bewusst nur eine -URL, damit das Ziel austauschbar bleibt. Wer einen eigenen Empfänger schreibt, sollte -die drei Regeln übernehmen: Länge prüfen, bei PDF `%%EOF` prüfen, Kennung erst nach -erfolgreicher Ablage merken. +In production something else takes its place here - storage in a cloud, a document +system or a folder. The firmware deliberately knows only a URL, so that the destination +stays interchangeable. Anyone writing their own receiver should adopt the three rules: +check the length, check `%%EOF` for PDFs, remember the identifier only after successful +storage. -## Prüfstand ohne Drucker +## Test bench without a printer -`test/stresstest.sh` läuft auf einem Linux-Rechner, an dem der Stick steckt (ein -Raspberry Pi eignet sich). Der Rechner spielt den Drucker: mounten, Test-PDFs -schreiben, auswerfen. Ein Empfänger auf demselben Rechner prüft, ob jede Datei -vollständig und genau einmal ankommt. Drei Szenarien: eine Datei, zwei in einem Zug, -und eine zweite Datei im engsten Moment, sobald das Medium nach dem ersten Upload -wieder da ist. +`test/stresstest.sh` runs on a Linux machine with the stick plugged in (a Raspberry Pi +works well). The machine plays the printer: mount, write test PDFs, eject. A receiver on +the same machine checks whether every file arrives complete and exactly once. Three +scenarios: one file, two in one go, and a second file at the tightest moment, as soon as +the medium is back after the first upload. ```bash -sudo SCAN_WEBPASS=PASSWORT test/stresstest.sh http://scanstick-1a2b.local /dev/sda1 +sudo SCAN_WEBPASS=PASSWORD test/stresstest.sh http://scanstick-1a2b.local /dev/sda1 ``` -Das Upload-Ziel des Sticks wird für die Dauer des Tests umgestellt und danach -zurückgesetzt. Die Partition wird nur angefasst, wenn sie zu einem Espressif-USB-Gerät -gehört und `SCANS` heißt. Der Linux-Treiber schreibt Verzeichniseinträge früher und -anders als ein Drucker; der Prüfstand ersetzt den Drucktest nicht, macht aber -Regressionen wiederholbar sichtbar. +The stick's upload target is switched over for the duration of the test and reset +afterwards. The partition is only touched if it belongs to an Espressif USB device and +is named `SCANS`. The Linux driver writes directory entries earlier and differently than +a printer; the test bench does not replace the print test, but it does make regressions +repeatably visible. -### Wo der Empfänger laufen sollte +### Where the receiver should run -Das Skript läuft ohne Zusatzpakete auf Linux, macOS und Windows. Entscheidend ist aber -nicht das Betriebssystem, sondern die **Verfügbarkeit**: Der Empfänger muss laufen, -wenn jemand scannt. Schläft der Rechner, bleibt die Datei auf der Karte liegen und wird -beim nächsten Anlauf erneut versucht — angekommen ist sie aber nicht. +The script runs without extra packages on Linux, macOS and Windows. What matters is not +the operating system, however, but **availability**: the receiver has to be running when +somebody scans. If the machine is asleep, the file stays on the card and is retried on +the next attempt - but it has not arrived. -| Gastgeber | Eignung | +| Host | Suitability | |---|---| -| Raspberry Pi | ideal: läuft durch, wenig Strom, als Dienst einrichtbar | -| NAS | sehr gut, Ablage direkt am Ziel | -| Server / VM | gut, sofern vom Stick erreichbar | -| Arbeitsplatzrechner | nur solange er wach ist | +| Raspberry Pi | ideal: runs continuously, little power, can be set up as a service | +| NAS | very good, storage right at the destination | +| Server / VM | good, as long as it is reachable from the stick | +| Workstation | only while it is awake | -### Firewall und Autostart +### Firewall and autostart -**macOS** — beim ersten Start erscheint „Eingehende Netzwerkverbindungen zulassen?", -das muss erlaubt werden (nachträglich unter *Systemeinstellungen → Netzwerk → Firewall -→ Optionen*). Port 8080 braucht keine Administratorrechte. Dauerhaft über einen -LaunchAgent in `~/Library/LaunchAgents/`; der Rechner darf dann nicht in den -Ruhezustand gehen. +**macOS** - on the first start "Allow incoming network connections?" appears, which has +to be allowed (afterwards under *System Settings -> Network -> Firewall -> Options*). +Port 8080 needs no administrator rights. Permanently via a LaunchAgent in +`~/Library/LaunchAgents/`; the machine must then not go to sleep. -**Windows** — die Defender-Firewall fragt beim ersten Start nach; Haken bei *Privates -Netzwerk*, öffentliche Netzwerke nicht. Nachträglich: *Eingehende Regel → Port → TCP -8080 → zulassen*, Profil „Privat". Dauerhaft über die Aufgabenplanung („Beim Start des -Computers") oder als Dienst. +**Windows** - the Defender firewall asks on the first start; tick *Private network*, not +public networks. Afterwards: *inbound rule -> port -> TCP 8080 -> allow*, profile +"Private". Permanently via Task Scheduler ("at computer startup") or as a service. -**Linux** — falls eine Firewall aktiv ist: `sudo ufw allow 8080/tcp` beziehungsweise -`firewall-cmd --add-port=8080/tcp --permanent`. Dauerhaft als systemd-Unit: +**Linux** - if a firewall is active: `sudo ufw allow 8080/tcp` or +`firewall-cmd --add-port=8080/tcp --permanent`. Permanently as a systemd unit: ```ini # /etc/systemd/system/scan-receiver.service [Unit] -Description=Scan-Stick Empfaenger +Description=Scan-Stick receiver After=network-online.target [Service] ExecStart=/usr/bin/python3 -u /opt/scanstick/empfaenger/scan-receiver.py Environment=SCAN_PORT=8080 Environment=SCAN_INBOX=/srv/scans -Environment=SCAN_KEY=geheim +Environment=SCAN_KEY=secret Restart=always User=pi @@ -420,34 +416,34 @@ WantedBy=multi-user.target sudo systemctl enable --now scan-receiver ``` -**Zwei Dinge, die man leicht vergisst:** +**Two things that are easy to forget:** -1. Der Empfänger braucht eine **feste Adresse** — im Router reserviert oder statisch - vergeben. Bekommt er per DHCP eine neue, zeigt `endpoint=` ins Leere, und zwar - irgendwann mitten im Betrieb. -2. Er nimmt **alles** entgegen, was an ihn geschickt wird. Im Heimnetz ist das - vertretbar, aus dem Internet erreichbar sollte er nicht sein. +1. The receiver needs a **fixed address** - reserved in the router or assigned + statically. If it gets a new one by DHCP, `endpoint=` points nowhere, and it happens + at some point in the middle of operation. +2. It accepts **everything** that is sent to it. On a home network that is acceptable, + but it should not be reachable from the internet. -## Anzeige +## Display -| Anzeige | Bedeutung | +| Display | Meaning | |---|---| -| STROM | fährt hoch | -| BEREIT | wartet, mit Empfangsbalken | -| OK | Drucker greift gerade zu | -| SCAN ERKANNT | Schreibvorgang bemerkt, zeigt Countdown oder erkannte Größe | -| SENDET | Fortschrittsbalken mit Prozent und Dateiname | -| WARTE | Drucker noch nicht fertig, mit Versuchszähler | -| SD?! | Karte nicht lesbar | +| POWER | booting | +| READY | waiting, with signal bar | +| OK | printer is accessing right now | +| SCAN FOUND | write noticed, shows countdown or detected size | +| SENDING | progress bar with percentage and file name | +| WAIT | printer not finished yet, with attempt counter | +| SD?! | card not readable | -## Offene Punkte +## Open points -- Selbsttätiger Wechsel des Zugangspunkts, wenn der Empfang längere Zeit schwach bleibt - (bei komplettem Ausfall sucht er nach zwei Minuten neu; bei nur schwachem Empfang noch nicht) -- Auch andere Dateitypen als PDF auf Vollständigkeit prüfen -- Weitere Boards, insbesondere solche ohne Kartensteckplatz (dort müsste der interne - Flash als Speicher dienen) +- Automatic switching of the access point when reception stays weak for a longer time + (on a complete outage it scans again after two minutes; on merely weak reception not yet) +- Check file types other than PDF for completeness as well +- Further boards, in particular ones without a card slot (there the internal flash would + have to serve as storage) -## Lizenz +## License -MIT, siehe `LICENSE`. +MIT, see `LICENSE`. diff --git a/empfaenger/__pycache__/scan-receiver.cpython-314.pyc b/empfaenger/__pycache__/scan-receiver.cpython-314.pyc deleted file mode 100644 index c2aa531..0000000 Binary files a/empfaenger/__pycache__/scan-receiver.cpython-314.pyc and /dev/null differ diff --git a/empfaenger/scan-receiver.py b/empfaenger/scan-receiver.py index a5447b9..b64932a 100644 --- a/empfaenger/scan-receiver.py +++ b/empfaenger/scan-receiver.py @@ -1,30 +1,29 @@ #!/usr/bin/env python3 -"""Winziger Scan-Empfaenger: nimmt POST /scan?name=... entgegen und legt die -Datei in ~/scan-inbox/ ab. Nur zum Testen der Scan-Stick-Kette gedacht; -im Betrieb tritt hier die eigentliche Ablage an diese Stelle. +"""Tiny scan receiver: accepts POST /scan?name=... and stores the file in +~/scan-inbox/. Meant for testing the Scan-Stick chain; in production the +real archiving step takes its place here. -Port und Ablageordner lassen sich ueber die Umgebung setzen: +Port and inbox folder can be set through the environment: SCAN_PORT=8080 SCAN_INBOX=~/scan-inbox python3 scan-receiver.py """ import os, time, hmac, hashlib, threading, http.server, socketserver, urllib.parse PORT = int(os.environ.get("SCAN_PORT", "8080")) INBOX = os.path.expanduser(os.environ.get("SCAN_INBOX", "~/scan-inbox")) -# Geraeteschluessel: ist er gesetzt, muss jeder Upload eine gueltige Kopfzeile -# X-Scan-Auth tragen (HMAC-SHA256 ueber "name\nid\nlaenge", derselbe Schluessel -# wie in den Einstellungen des Sticks). Ohne Schluessel nimmt der Empfaenger -# alles an - im Heimnetz vertretbar, fuer den Dauerbetrieb nicht. +# Device key: if set, every upload must carry a valid X-Scan-Auth header +# (HMAC-SHA256 over "name\nid\nlength", same key as in the stick's settings). +# Without a key the receiver accepts everything - acceptable on a home +# network, not for permanent use. SCHLUESSEL = os.environ.get("SCAN_KEY", "").encode() -# Der Stick schickt sein Protokoll an denselben Endpunkt (scanlog-*.txt). Das -# gehoert nicht zwischen die Scans, sondern in einen eigenen Ordner. +# The stick sends its log to the same endpoint (scanlog-*.txt). That does +# not belong between the scans but in a folder of its own. PROTOKOLL = os.path.join(INBOX, "protokoll") os.makedirs(PROTOKOLL, exist_ok=True) -# Der Stick haengt an jeden Upload eine Kennung der Datei. Kommt dieselbe -# Kennung ein zweites Mal, ist es eine Wiederholung - etwa nach einem -# abgebrochenen Upload oder weil der Drucker seine alte Verzeichnissicht -# zurueckgeschrieben hat und die Datei erneut auftauchte. Wir bestaetigen -# solche Uploads, legen sie aber nicht noch einmal ab. +# The stick attaches an id of the file to every upload. If the same id +# arrives a second time it is a repeat - after an aborted upload, or because +# the printer wrote back its stale directory view and the file reappeared. +# We acknowledge such uploads but do not store them again. IDLISTE = os.path.join(INBOX, ".empfangene-ids") @@ -48,7 +47,7 @@ def id_merken(scan_id): def ordner_sichern(pfad): - """Verzeichniseintrag auf die Platte zwingen (POSIX); anderswo egal.""" + """Force the directory entry to disk (POSIX); harmless elsewhere.""" try: fd = os.open(pfad, os.O_RDONLY) try: @@ -60,13 +59,12 @@ def ordner_sichern(pfad): def ablegen(ordner, name, daten): - """Datei absturzsicher ablegen: erst in eine temporaere Datei schreiben, - auf die Platte zwingen, dann unter dem endgueltigen Namen einhaengen. - Stirbt der Rechner mittendrin, bleibt hoechstens eine .teil-Datei uebrig, - nie eine halbe PDF unter richtigem Namen. Erst danach wird die Kennung - gemerkt - eine gemerkte Kennung ohne sicher liegende Datei waere die - einzige Kombination, die einen Scan wirklich verlieren kann. - Liefert den endgueltigen Pfad.""" + """Store a file crash-safely: write to a temporary file first, force it + to disk, then link it under its final name. If the machine dies halfway, + at most a .teil file is left behind, never a half PDF under the real + name. Only after that is the id remembered - a remembered id without a + safely stored file would be the one combination that really loses a scan. + Returns the final path.""" tmp = os.path.join(ordner, "." + name + ".teil") with open(tmp, "wb") as f: f.write(daten) @@ -76,12 +74,12 @@ def ablegen(ordner, name, daten): ziel, n = basis + ext, 1 while True: try: - os.link(tmp, ziel) # atomar und exklusiv: scheitert, wenn es den Namen gibt + os.link(tmp, ziel) # atomic and exclusive: fails if the name exists break except FileExistsError: ziel = "%s_%d%s" % (basis, n, ext); n += 1 except OSError: - os.replace(tmp, ziel) # Dateisystem ohne harte Verweise + os.replace(tmp, ziel) # file system without hard links ordner_sichern(ordner) return ziel os.unlink(tmp) @@ -89,20 +87,20 @@ def ablegen(ordner, name, daten): return ziel -# Zwei gleichzeitige Uploads derselben Kennung (Wiederholung des Sticks waehrend -# der erste noch laeuft) duerfen nicht beide durchkommen. +# Two simultaneous uploads with the same id (the stick retrying while the +# first one is still running) must not both get through. SPERRE = threading.Lock() def vollstaendig(name, daten, angekuendigt): - """Nur eine vollstaendige Datei darf abgelegt und als empfangen gemerkt - werden. Vorher wurde ein abgerissener Upload verkuerzt gespeichert und - seine Kennung gemerkt - die Wiederholung des Sticks galt dann als Duplikat, - und der Stick loeschte daraufhin das einzige vollstaendige Exemplar.""" + """Only a complete file may be stored and remembered as received. Earlier + a torn upload was stored truncated and its id remembered - the stick's + retry then counted as a duplicate, and the stick deleted the only + complete copy.""" if len(daten) != angekuendigt: - return "nur %d von %d Bytes angekommen" % (len(daten), angekuendigt) + return "only %d of %d bytes arrived" % (len(daten), angekuendigt) if name.lower().endswith(".pdf") and b"%%EOF" not in daten[-1024:]: - return "PDF ohne Endmarke %%EOF" + return "PDF without %%EOF end marker" return None @@ -116,9 +114,10 @@ def _antwort(self, code, text, duplikat=False): self.send_response(code) self.send_header("Content-Type", "text/plain") if duplikat: - # Der Stick liest das Wort "Duplikat" aus der Antwort und traegt - # seinen Geist-Eintrag dann roh aus, statt ihn zu verschieben. - self.send_header("X-Scan-Duplikat", "ja") + # The stick looks for the word "duplicate" (or the older German + # "Duplikat") in the reply and then drops its ghost entry raw + # instead of moving it. + self.send_header("X-Scan-Duplicate", "yes") self.end_headers() self.wfile.write(text.encode() + b"\n") @@ -128,7 +127,7 @@ def do_POST(self): name_roh = params.get("name", [""])[0] id_roh = params.get("id", [""])[0] name = self._sicherer_name(name_roh) - scan_id = self._sicherer_name(id_roh) if id_roh else "" # ohne Kennung nichts merken + scan_id = self._sicherer_name(id_roh) if id_roh else "" # nothing to remember without an id laenge = int(self.headers.get("Content-Length", 0)) daten = self.rfile.read(laenge) if laenge else b"" stempel = time.strftime("%H:%M:%S") @@ -138,36 +137,36 @@ def do_POST(self): soll = hmac.new(SCHLUESSEL, nachricht, hashlib.sha256).hexdigest() ist = self.headers.get("X-Scan-Auth", "") if not hmac.compare_digest(soll, ist): - print("[%s] ABGELEHNT %s von %s: Signatur %s" % ( - stempel, name, self.client_address[0], "fehlt" if not ist else "falsch")) - self._antwort(401, "abgelehnt: Signatur fehlt oder falsch") + print("[%s] REJECTED %s from %s: signature %s" % ( + stempel, name, self.client_address[0], "missing" if not ist else "wrong")) + self._antwort(401, "rejected: signature missing or wrong") return fehler = vollstaendig(name, daten, laenge) if fehler: - print("[%s] ABGELEHNT %s: %s" % (stempel, name, fehler)) - self._antwort(400, "abgelehnt: " + fehler) + print("[%s] REJECTED %s: %s" % (stempel, name, fehler)) + self._antwort(400, "rejected: " + fehler) return ordner = PROTOKOLL if name.startswith("scanlog-") else INBOX with SPERRE: if id_bekannt(scan_id): - print("[%s] schon empfangen (%s), verworfen: %s (%d Bytes)" % ( + print("[%s] already received (%s), discarded: %s (%d bytes)" % ( stempel, scan_id, name, len(daten))) - self._antwort(200, "OK (Duplikat, nicht erneut abgelegt)", duplikat=True) + self._antwort(200, "OK (duplicate, not stored again)", duplikat=True) return - ziel = ablegen(ordner, name, daten) # Datei sicher, dann erst die Kennung + ziel = ablegen(ordner, name, daten) # file safe first, then the id id_merken(scan_id) - print("[%s] empfangen: %s (%d Bytes, id %s) von %s" % ( + print("[%s] received: %s (%d bytes, id %s) from %s" % ( stempel, os.path.basename(ziel), len(daten), - scan_id or "ohne", self.client_address[0])) + scan_id or "none", self.client_address[0])) self._antwort(200, "OK") def do_GET(self): - self._antwort(200, "scan-receiver laeuft") + self._antwort(200, "scan-receiver is running") def log_message(self, *a): - pass # eigene Ausgabe reicht + pass # our own output is enough class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): @@ -175,5 +174,5 @@ class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): if __name__ == "__main__": - print("scan-receiver auf 0.0.0.0:%d, Ablage %s" % (PORT, INBOX)) + print("scan-receiver on 0.0.0.0:%d, inbox %s" % (PORT, INBOX)) Server(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/firmware/scanner/scanner.ino b/firmware/scanner/scanner.ino index cd7f01b..77935de 100644 --- a/firmware/scanner/scanner.ino +++ b/firmware/scanner/scanner.ino @@ -1,17 +1,17 @@ /** - * Scan-Stick fuer LilyGo T-Dongle-S3 - * Gibt sich am Drucker als USB-Stick (FAT32 "SCANS") aus. Erkennt, wann ein Scan - * fertig geschrieben ist (Ruhe nach dem letzten Sektor-Write), laedt jede neue - * Datei per HTTP-POST an einen konfigurierbaren Empfaenger und meldet den Stick - * danach neu an, damit der Drucker wieder einen leeren Stick sieht. + * Scan-Stick for LilyGo T-Dongle-S3 + * Presents itself to the printer as a USB stick (FAT32 "SCANS"). Detects when a scan + * has been written completely (idle after the last sector write), uploads every new + * file by HTTP POST to a configurable receiver and re-announces the stick + * afterwards, so the printer sees an empty stick again. * - * Diagnose-Log geht per HTTP an denselben Empfaenger (scanlog-.txt), - * NICHT auf die Karte - sonst zerstoert der eigene FAT-Write den Commit des Druckers. + * The diagnostic log goes by HTTP to the same receiver (scanlog-.txt), + * NOT onto the card - otherwise our own FAT write destroys the printer's commit. * - * Zugangsdaten kommen NICHT in den Code, sondern aus /wifi.cfg auf der SD-Karte: + * Credentials do NOT belong in the code, they come from /wifi.cfg on the SD card: * ssid=... * pass=... - * endpoint=http://192.168.1.50:8080/scan (Beispiel - die Adresse des eigenen Empfaengers) + * endpoint=http://192.168.1.50:8080/scan (example - the address of your own receiver) * * Board: ESP32S3 Dev Module, USB Mode = USB-OTG (TinyUSB), USB CDC on Boot = Enabled */ @@ -40,21 +40,21 @@ #define SD_CLK 12 #define SD_CMD 16 -// Datentypen, die in Funktionssignaturen vorkommen, stehen hier oben: der Arduino- -// Builder erzeugt Prototypen vor der ersten Funktion und muss die Typen dort kennen. +// Data types that appear in function signatures live up here: the Arduino +// builder creates prototypes before the first function and must know the types there. struct RohEintrag { uint32_t start; uint32_t groesse; char name[64]; }; struct Fertig { uint32_t start, groesse, kennzahl; char name[40]; }; struct KartenBefund { int fatAbweichungen, kreuz, verwaist, gekuerzt; bool schmutzig, heillos, geaendert, zuGross; }; -#define IDLE_VORGABE 45000 // Ruhe bis "Scan fertig" - Vorgabe, per Weboberflaeche aenderbar -static uint32_t g_idleMs = IDLE_VORGABE; // Ruhefrist, aus dem Flash -static bool g_loeschen = true; // true = loeschen, false = nach /gesendet verschieben +#define IDLE_VORGABE 45000 // idle until "scan done" - default, changeable via the web interface +static uint32_t g_idleMs = IDLE_VORGABE; // idle deadline, from the flash +static bool g_loeschen = true; // true = delete, false = move to /gesendet #define IDLE_MS (g_idleMs) -#define RETRY_MS 30000 // nichts gefunden -> so lange warten, dann nochmal schauen -#define MAX_VERSUCHE 5 // so oft nachschauen, bevor wir aufgeben +#define RETRY_MS 30000 // nothing found -> wait this long, then look again +#define MAX_VERSUCHE 5 // look this many times before we give up #define WIFI_TIMEOUT 20000 -// ---- APA102 Status-LED (T-Dongle-S3: Daten 40, Takt 39) ---- +// ---- APA102 status LED (T-Dongle-S3: data 40, clock 39) ---- #define LED_DI 40 #define LED_CI 39 static void ledByte(uint8_t b) { @@ -66,25 +66,25 @@ static void ledByte(uint8_t b) { } } static uint8_t g_ledR = 0, g_ledG = 0, g_ledB = 0; -// Die APA102 hat ein eigenes Helligkeitsbyte (0..31). Volle Helligkeit ist als -// Dauerlicht neben einem Drucker schlicht zu grell - einstellbar, 0 = aus. +// The APA102 has its own brightness byte (0..31). Full brightness as a +// permanent light next to a printer is simply too glaring - adjustable, 0 = off. static uint8_t g_ledHell = 5; static void ledRaw(uint8_t r, uint8_t g, uint8_t b) { - ledByte(0); ledByte(0); ledByte(0); ledByte(0); // Start-Frame - if (!g_ledHell) { r = g = b = 0; } // ganz aus + ledByte(0); ledByte(0); ledByte(0); ledByte(0); // start frame + if (!g_ledHell) { r = g = b = 0; } // fully off ledByte(0xE0 | (g_ledHell & 0x1F)); - ledByte(b); ledByte(g); ledByte(r); // BGR-Reihenfolge - ledByte(0xFF); ledByte(0xFF); ledByte(0xFF); ledByte(0xFF); // End-Frame + ledByte(b); ledByte(g); ledByte(r); // BGR order + ledByte(0xFF); ledByte(0xFF); ledByte(0xFF); ledByte(0xFF); // end frame } static void ledColor(uint8_t r, uint8_t g, uint8_t b) { g_ledR = r; g_ledG = g; g_ledB = b; ledRaw(r, g, b); } static void ledHeartbeat() { - if (!g_ledHell) return; // aus bleibt aus, auch beim Lebenszeichen + if (!g_ledHell) return; // off stays off, even for the heartbeat ledRaw(0, 0, 0); delay(40); ledRaw(g_ledR, g_ledG, g_ledB); } static void ledInit() { pinMode(LED_DI, OUTPUT); pinMode(LED_CI, OUTPUT); } -// ---- ST7735-Display (T-Dongle-S3: CS4 SDA3 SCL5 DC2 RST1 Backlight38) ---- +// ---- ST7735 display (T-Dongle-S3: CS4 SDA3 SCL5 DC2 RST1 Backlight38) ---- #define TFT_CS 4 #define TFT_SDA 3 #define TFT_SCL 5 @@ -93,8 +93,8 @@ static void ledInit() { pinMode(LED_DI, OUTPUT); pinMode(LED_CI, OUTPUT); } #define TFT_BL 38 #define COL_BLACK 0x0000 #define COL_WHITE 0xFFFF -// Zustaende des Sticks. Display-Hintergrund und Status-LED teilen sich eine -// Farbe pro Zustand - vorher wurden beide getrennt gesetzt und liefen auseinander. +// States of the stick. Display background and status LED share one +// color per state - before, both were set separately and drifted apart. #define Z_STROM 0 #define Z_BEREIT 1 #define Z_HOST 2 @@ -105,15 +105,15 @@ static void ledInit() { pinMode(LED_DI, OUTPUT); pinMode(LED_CI, OUTPUT); } #define Z_ANZAHL 7 static const char *Z_NAME[Z_ANZAHL] = { - "Strom da, startet", "Bereit, wartet auf den Drucker", "Drucker greift zu", - "Sucht neue Scans", "Karte nicht lesbar", "Wartet auf den Commit", - "Scan erkannt, Ruhefrist laeuft" + "Power on, starting", "Ready, waiting for the printer", "Printer is accessing", + "Looking for new scans", "Card not readable", "Waiting for the commit", + "Scan found, idle deadline running" }; -static const char *Z_TEXT[Z_ANZAHL] = { "STROM", "BEREIT", "OK", "SUCHE", "SD?!", "WARTE", "SCAN" }; -// Vorgaben als 0xRRGGBB, per Weboberflaeche aenderbar +static const char *Z_TEXT[Z_ANZAHL] = { "POWER", "READY", "OK", "SEARCH", "SD?!", "WAIT", "SCAN" }; +// Defaults as 0xRRGGBB, changeable via the web interface static uint32_t g_farbe[Z_ANZAHL] = { 0xFFC000, 0x0044FF, 0x00C000, 0xCC00CC, 0xFF0000, 0xFFAA00, 0x00AACC }; -static uint32_t g_farbeSendet = 0x00AAFF; // waehrend der Uebertragung -static uint32_t g_farbeFertig = 0x00CC44; // Erfolgsmeldung +static uint32_t g_farbeSendet = 0x00AAFF; // during the transfer +static uint32_t g_farbeFertig = 0x00CC44; // success message static uint16_t rgb565(uint32_t rgb) { @@ -121,22 +121,22 @@ static uint16_t rgb565(uint32_t rgb) return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3); } static Arduino_DataBus *g_bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCL, TFT_SDA); -static Arduino_GFX *g_gfx = new Arduino_ST7735(g_bus, TFT_RST, 1 /*Rotation*/, false /*ips*/, 80, 160, 26, 1, 26, 1); -static int g_screen = -1; // aktueller Anzeigezustand, nur bei Wechsel neu zeichnen -static bool g_invertiert = true; // Display stellt die Farben negativ dar -static int g_wlanStufeLetzt = -1; // zuletzt gezeichnete Empfangsstufe +static Arduino_GFX *g_gfx = new Arduino_ST7735(g_bus, TFT_RST, 1 /*rotation*/, false /*ips*/, 80, 160, 26, 1, 26, 1); +static int g_screen = -1; // current display state, redraw only on a change +static bool g_invertiert = true; // display shows the colors inverted +static int g_wlanStufeLetzt = -1; // last drawn signal level static uint32_t g_wlanLetzt = 0; static long g_rssiMin = 0, g_rssiMax = 0, g_rssiLetztLast = 0; static long g_rssiSumme = 0; static uint32_t g_rssiAnzahl = 0; -// zeichnet einen einfachen Blitz an Position (x,y) +// draws a simple lightning bolt at position (x,y) static void malBlitz(int x, int y, uint16_t farbe) { g_gfx->fillTriangle(x+14, y, x+2, y+20, x+12, y+18, farbe); g_gfx->fillTriangle(x+12, y+16, x+22, y+14, x+8, y+36, farbe); } -// Ein Zustand setzt Display UND LED - eine Farbe, eine Quelle. +// One state sets display AND LED - one color, one source. static void zeigeScreen(int z) { if (z == g_screen || z < 0 || z >= Z_ANZAHL) return; @@ -149,15 +149,15 @@ static void zeigeScreen(int z) g_gfx->setTextSize(z == Z_HOST ? 5 : 3); g_gfx->setCursor(z == Z_HOST ? 60 : 30, 30); g_gfx->print(Z_TEXT[z]); - g_wlanStufeLetzt = -1; // fillScreen hat die Balken geloescht + g_wlanStufeLetzt = -1; // fillScreen has erased the bars } -// ---- Empfangsbalken oben rechts ---- -// Der Empfang schwankt am Druckerstandort erheblich (im Metallgehaeuse -93 dBm, -// aussen -61). Man soll das am Geraet sehen, ohne die Weboberflaeche zu oeffnen. -// Empfang laufend mitschreiben: heute schwankte er bei gleicher Position -// zwischen -93 und -61 dBm. Ohne Aufzeichnung ist nicht zu unterscheiden, -// ob das dauernd passiert oder nur unter Sendelast. +// ---- signal bars top right ---- +// Reception varies considerably at the printer location (inside the metal case -93 dBm, +// outside -61). You should see that on the device without opening the web interface. +// Record reception continuously: today it varied at the same position +// between -93 and -61 dBm. Without a record there is no telling +// whether that happens all the time or only under send load. static void rssiErfassen(long r) { if (r >= 0 || r < -110) return; @@ -188,26 +188,26 @@ static void zeichneWlanBalken() int z = (g_screen >= 0 && g_screen < Z_ANZAHL) ? g_screen : Z_BEREIT; uint16_t hg = rgb565(g_farbe[z]); - g_gfx->fillRect(110, 1, 49, 24, hg); // Bereich freiraeumen + g_gfx->fillRect(110, 1, 49, 24, hg); // clear the area for (int i = 0; i < 5; i++) { - int hoehe = 4 + i * 4; // 4, 8, 12, 16, 20 Pixel + int hoehe = 4 + i * 4; // 4, 8, 12, 16, 20 pixels int x = 112 + i * 9; int y = 22 - hoehe; - if (i < stufe) g_gfx->fillRect(x, y, 7, hoehe, COL_BLACK); // voll = vorhanden - else g_gfx->drawRect(x, y, 7, hoehe, COL_BLACK); // nur Umriss + if (i < stufe) g_gfx->fillRect(x, y, 7, hoehe, COL_BLACK); // filled = present + else g_gfx->drawRect(x, y, 7, hoehe, COL_BLACK); // outline only } - if (stufe == 0) { // kein Netz: Kreuz ueber die Balken + if (stufe == 0) { // no network: cross over the bars g_gfx->drawLine(112, 2, 156, 22, COL_BLACK); g_gfx->drawLine(112, 22, 156, 2, COL_BLACK); } } -// ---- dynamische Anzeigen: gefunden / sendet / fertig ---- -// Das Display ist im Betrieb die einzige Rueckmeldung am Geraet, deshalb soll -// man sehen, WAS er sendet und dass es vorangeht - nicht nur "irgendwas laeuft". +// ---- dynamic screens: found / sending / done ---- +// In operation the display is the only feedback on the device, so you should +// see WHAT it sends and that it makes progress - not just "something is running". -// Dateinamen auf die Displaybreite kuerzen (Textgroesse 1 = 6 px pro Zeichen) +// shorten file names to the display width (text size 1 = 6 px per character) static String kurzName(const String &name, int max_zeichen) { if ((int)name.length() <= max_zeichen) return name; @@ -218,7 +218,7 @@ static int g_balkenProzent = -1; static void zeigeSendenStart(const String &name, uint32_t gesamt) { - g_screen = -2; // Sonderzustand: loop() zeichnet neu, wenn es vorbei ist + g_screen = -2; // special state: loop() redraws when it is over g_balkenProzent = -1; uint32_t rgb = g_farbeSendet; ledColor(rgb >> 16, (rgb >> 8) & 0xFF, rgb & 0xFF); @@ -226,19 +226,19 @@ static void zeigeSendenStart(const String &name, uint32_t gesamt) g_gfx->setTextColor(COL_BLACK); g_gfx->setTextSize(2); g_gfx->setCursor(4, 4); - g_gfx->print("SENDET"); + g_gfx->print("SENDING"); g_gfx->setTextSize(1); g_gfx->setCursor(4, 24); g_gfx->print(kurzName(name, 25)); g_gfx->setCursor(4, 64); g_gfx->printf("%u kB", (unsigned)(gesamt / 1024)); - g_gfx->drawRect(4, 38, 152, 18, COL_BLACK); // Rahmen des Balkens + g_gfx->drawRect(4, 38, 152, 18, COL_BLACK); // frame of the bar } static void zeigeSendenFortschritt(uint32_t fertig, uint32_t gesamt) { int proz = gesamt ? (int)((uint64_t)fertig * 100 / gesamt) : 100; - if (proz == g_balkenProzent) return; // nur bei Aenderung zeichnen, sonst flackert es + if (proz == g_balkenProzent) return; // draw only on a change, otherwise it flickers g_balkenProzent = proz; int breite = (150 * proz) / 100; g_gfx->fillRect(5, 39, breite, 16, COL_BLACK); @@ -261,28 +261,28 @@ static void zeigeFertig(uint32_t bytes) g_gfx->print("OK"); g_gfx->setTextSize(1); g_gfx->setCursor(20, 62); - g_gfx->printf("%u kB gesendet", (unsigned)(bytes / 1024)); + g_gfx->printf("%u kB sent", (unsigned)(bytes / 1024)); } -// Ruhefrist laeuft: gross den Countdown zeigen. Vorher stand hier 45 Sekunden -// lang ein unveraendertes Bild - man konnte nicht sehen, ob er den Scan hat. +// Idle deadline running: show the countdown large. Before, an unchanged image +// stood here for 45 seconds - you could not see whether it had the scan. static int g_fristLetzt = -1; -static bool g_warteAnzeige = false; // WARTE-Bild steht und darf nicht ueberschrieben werden +static bool g_warteAnzeige = false; // WAIT screen is up and must not be overwritten -// Ergebnis des rohen Mitlesens - auch die Anzeige greift darauf zu -#define ROH_INTERVALL 1000 // so oft roh nachsehen (ms) -#define ROH_RUHE 3000 // so lange kein Schreibzugriff, bevor wir zugreifen -#define ROH_STABIL 3 // so viele gleiche Messungen = Datei fertig +// result of the raw reading - the display accesses it too +#define ROH_INTERVALL 1000 // look raw this often (ms) +#define ROH_RUHE 3000 // no write access for this long before we access +#define ROH_STABIL 3 // this many equal readings = file done static uint32_t g_rohLetzt = 0; static uint32_t g_rohSumme = 0; static int g_rohAnzahl = 0; static int g_rohStabil = 0; -// Fuer die Anzeige zaehlt nur, was noch nicht gesendet ist - sonst stand nach -// jedem Schreibzugriff des Druckers "SCAN ERKANNT" mit der Groesse laengst -// gesendeter Dateien auf dem Display. +// For the display only what is not yet sent counts - otherwise after every +// write access of the printer "SCAN FOUND" stood on the display with the size +// of long since sent files. static int g_zeigAnzahl = 0; static uint32_t g_zeigSumme = 0; -static uint32_t g_rohGeprueft = 0; // millis() der letzten rohen Nachschau +static uint32_t g_rohGeprueft = 0; // millis() of the last raw look static int g_fristModus = -1; static void zeigeFrist(uint32_t restSek) @@ -298,7 +298,7 @@ static void zeigeFrist(uint32_t restSek) g_gfx->setTextColor(COL_BLACK); g_gfx->setTextSize(2); g_gfx->setCursor(5, 4); - g_gfx->print("SCAN ERKANNT"); + g_gfx->print("SCAN FOUND"); } if (modus != g_fristModus) { g_fristModus = modus; @@ -307,10 +307,10 @@ static void zeigeFrist(uint32_t restSek) g_gfx->setTextColor(COL_BLACK); g_gfx->setTextSize(1); g_gfx->setCursor(5, 68); - g_gfx->print(modus ? "pruefe ob fertig" : "warte auf Ruhe"); + g_gfx->print(modus ? "checking if done" : "waiting for idle"); } - // Sobald wir die neue Datei roh sehen, ist ihre Groesse die ehrlichere Angabe - // als ein Countdown, der ohnehin vorzeitig endet. + // As soon as we see the new file raw, its size is the more honest figure + // than a countdown that ends early anyway. int wert = modus ? (int)(g_zeigSumme / 1024) : (int)restSek; if (wert != g_fristLetzt) { g_fristLetzt = wert; @@ -333,72 +333,72 @@ static void zeigeWarte(int versuch, int von) g_gfx->setTextColor(COL_BLACK); g_gfx->setTextSize(3); g_gfx->setCursor(12, 14); - g_gfx->print("WARTE"); + g_gfx->print("WAIT"); g_gfx->setTextSize(1); g_gfx->setCursor(12, 50); - g_gfx->printf("Versuch %d von %d", versuch, von); + g_gfx->printf("Attempt %d of %d", versuch, von); g_gfx->setCursor(12, 62); - g_gfx->print("Drucker nicht fertig"); + g_gfx->print("Printer not finished"); } static void displayInit() { pinMode(TFT_BL, OUTPUT); g_gfx->begin(); - // Dieses Modul zeigt die Farben sonst als Negativ: eingestelltes Blau - // erscheint gelb, Schwarz erscheint weiss. Damit waeren alle Farbwaehler - // in den Einstellungen wirkungslos bzw. genau verkehrt herum. + // Otherwise this module shows the colors as a negative: a configured blue + // appears yellow, black appears white. That would leave every color picker + // in the settings without effect, or exactly the wrong way round. g_gfx->invertDisplay(g_invertiert); - digitalWrite(TFT_BL, LOW); // Backlight an (LILYGO: aktiv-low) + digitalWrite(TFT_BL, LOW); // backlight on (LILYGO: active low) g_gfx->fillScreen(COL_BLACK); } USBMSC MSC; static volatile uint32_t g_lastWrite = 0; -static volatile uint32_t g_lastHost = 0; // wann hat der Host zuletzt gelesen/geschrieben +static volatile uint32_t g_lastHost = 0; // when did the host last read/write static volatile bool g_dirty = false; -static volatile uint32_t g_bytesGeschrieben = 0; // seit dem letzten Verarbeiten +static volatile uint32_t g_bytesGeschrieben = 0; // since the last processing -static uint32_t g_naechsterVersuch = 0; // 0 = sofort faellig +static uint32_t g_naechsterVersuch = 0; // 0 = due immediately static int g_versuche = 0; -#define FW_VERSION "v39" +#define FW_VERSION "v40" String cfgEndpoint; -// Bekannte WLAN-Netze - mehrere, damit derselbe Stick an verschiedenen Standorten -// laeuft. Beim Suchlauf gewinnt ueber ALLE bekannten Netze hinweg der staerkste -// Zugangspunkt; WiFiMulti verbindet gezielt mit dessen Kennung und Kanal. +// Known WiFi networks - several, so the same stick runs at different locations. +// During the scan the strongest access point across ALL known networks wins; +// WiFiMulti connects specifically with its identifier and channel. #define MAX_NETZE 4 static String cfgNetzSsid[MAX_NETZE], cfgNetzPass[MAX_NETZE]; static int cfgNetze = 0; static WiFiMulti g_wifiMulti; -// Geraetename mit den letzten vier Stellen der Funkadresse: zwei Sticks im selben -// Netz hiessen sonst beide scanstick.local, und der Pruefstand sprach am 20.09.2026 -// prompt mit dem falschen. Der Name steht im Protokoll und auf der Statusseite. +// Device name with the last four digits of the radio address: otherwise two sticks +// in the same network were both called scanstick.local, and on 20.09.2026 the test +// rig promptly talked to the wrong one. The name is in the log and on the status page. static String geraeteName() { uint8_t mac[6]; - esp_read_mac(mac, ESP_MAC_WIFI_STA); // aus dem Chip, nicht vom Treiber: der liefert vor dem WLAN-Start nur Nullen + esp_read_mac(mac, ESP_MAC_WIFI_STA); // from the chip, not from the driver: that one delivers only zeros before the WiFi start char t[24]; snprintf(t, sizeof t, "scanstick-%02x%02x", mac[4], mac[5]); return String(t); } -String cfgWebPass; // Schutz der Weboberflaeche; leer = offen -String cfgPraefix = "scan"; // Namensanfang der Dateien, z.B. "buero-774" -String cfgSchluessel; // Geraeteschluessel: signiert jeden Upload; leer = ohne -static bool g_zeitOk = false; // NTP-Zeit vorhanden? -static bool g_einmalSchauen = false; // Knopf "Jetzt schauen": einmal, ohne Wartezyklus -static bool g_startGeprueft = false; // nach dem Hochlaufen einmal nachsehen +String cfgWebPass; // protection of the web interface; empty = open +String cfgPraefix = "scan"; // name prefix of the files, e.g. "buero-774" +String cfgSchluessel; // device key: signs every upload; empty = without +static bool g_zeitOk = false; // NTP time available? +static bool g_einmalSchauen = false; // button "Look now": once, without a wait cycle +static bool g_startGeprueft = false; // look once after start-up static WebServer g_web(80); static Preferences g_nvs; -// Ein Riegel fuer ALLE rohen Kartenzugriffe, aus dem USB-Task wie aus der -// Hauptschleife. Der Kartentreiber schuetzt nur einzelne Kommandos; ein -// Schreibvorgang besteht aber aus Daten UND dem Warten, bis die Karte fertig -// ist. Schiebt sich dazwischen ein Lesen der Hauptschleife (Verzeichnis, -// Endmarke, Upload), kann der Zugriff des Hosts scheitern - fuer den 780 ein -// Medienfehler (44.12.05), worauf er dem Port den Strom nimmt. +// One lock for ALL raw card accesses, from the USB task as well as from the +// main loop. The card driver protects only single commands; a write however +// consists of the data AND the wait until the card is done. If a read of the +// main loop (directory, end marker, upload) pushes in between, the host's +// access can fail - for the 780 a media error (44.12.05), whereupon it cuts +// the power to the port. static SemaphoreHandle_t g_sdRiegel = nullptr; static bool sdLesen(uint8_t *b, uint32_t sektor) { @@ -415,40 +415,40 @@ static bool sdSchreiben(uint8_t *b, uint32_t sektor) return ok; } static bool g_webAn = false; -static bool g_updateBegonnen = false; // Update.begin() ist tatsaechlich gelaufen - -// ---- MSC: der Host greift auf die SD zu, jeder Sektor laeuft durch uns ---- -// Uebergabe der Karte zwischen USB-Task und Hauptschleife. mediaPresent(false) -// haelt nur NEUE Kommandos ab - ein Lesevorgang, der gerade laeuft, laeuft weiter. -// Ein Linux-Host liest beim Aushaengen die FAT in 120-kB-Bloecken, das dauert -// laenger als die 200 ms, die wir vor SD_MMC.end() gewartet haben. Der USB-Task -// hing dann auf dem abgebauten Treiber, nach 5 s schlug der Task-Watchdog zu -// (im Pruefstand zweimal hintereinander, im Kernel-Log "cmd_age=5s"). -// Deshalb: Sperre setzen, warten bis kein Zugriff mehr laeuft, erst dann anfassen. -static volatile int g_usbZugriffe = 0; // gerade laufende onRead/onWrite -static volatile bool g_sdGesperrt = false; // Hauptschleife hat die Karte -// Diagnose: die letzten Schreibzugriffe des Hosts (Sektor, Anzahl) - daran ist -// zu sehen, ob er Verzeichnis, Belegungstabelle oder Daten schreibt. +static bool g_updateBegonnen = false; // Update.begin() has actually run + +// ---- MSC: the host accesses the SD, every sector runs through us ---- +// Handover of the card between USB task and main loop. mediaPresent(false) +// holds off only NEW commands - a read that is already running keeps running. +// On unmount a Linux host reads the FAT in 120 kB blocks, that takes longer +// than the 200 ms we waited before SD_MMC.end(). The USB task then hung on the +// torn-down driver, after 5 s the task watchdog struck (on the test rig twice +// in a row, in the kernel log "cmd_age=5s"). +// Therefore: set the lock, wait until no access is running, only then touch it. +static volatile int g_usbZugriffe = 0; // onRead/onWrite currently running +static volatile bool g_sdGesperrt = false; // main loop has the card +// Diagnosis: the last write accesses of the host (sector, count) - they show +// whether it writes the directory, the allocation table or data. #define SPUR_ANZAHL 32 static volatile uint32_t g_spurLba[SPUR_ANZAHL], g_spurN[SPUR_ANZAHL], g_spurT[SPUR_ANZAHL]; static volatile int g_spurIdx = 0; -// Abgewiesene Host-Zugriffe: Antwort -1 heisst fuer den Drucker "Medienfehler". -// Passiert das mitten in einem Scan, bricht er still ab - deshalb zaehlen und -// im Protokoll und auf der Statusseite zeigen. +// Rejected host accesses: the answer -1 means "media error" to the printer. +// If that happens in the middle of a scan, it aborts silently - so count them +// and show them in the log and on the status page. static volatile uint32_t g_usbFehlerLesen = 0, g_usbFehlerSchreiben = 0; static volatile uint32_t g_usbFehlerLba = 0, g_usbFehlerZeit = 0; static uint32_t g_usbFehlerGemeldet = 0; static volatile bool g_usbFehlerNeu = false; -static String g_usbFehlerAlt; // Befund aus dem Flash: was vor dem letzten Neustart passierte -static void logZeile(const String &msg); // steht weiter unten -static bool remount(); // ebenfalls -static void geloeschtMerken(const Fertig &f); // ebenfalls -static void logFlushNetz(); // ebenfalls +static String g_usbFehlerAlt; // finding from the flash: what happened before the last restart +static void logZeile(const String &msg); // further below +static bool remount(); // likewise +static void geloeschtMerken(const Fertig &f); // likewise +static void logFlushNetz(); // likewise static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { - // Rueckgabe 0 heisst fuer TinyUSB "beschaeftigt, gleich nochmal" - bei einem - // echten Kartenfehler haengt der Host damit endlos. Negativ = sauberer Fehler. + // A return of 0 means "busy, try again shortly" to TinyUSB - with a real + // card error that hangs the host forever. Negative = clean error. if (g_sdGesperrt) { g_usbFehlerSchreiben++; g_usbFehlerLba = lba; g_usbFehlerZeit = millis(); g_usbFehlerNeu = true; g_lastHost = millis(); return -1; } g_usbZugriffe++; int32_t ergebnis = -1; @@ -462,7 +462,7 @@ static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t if (ergebnis > 0) { g_lastWrite = millis(); g_dirty = true; - g_bytesGeschrieben += bufsize; // Messung: kommen ueberhaupt Scandaten an? + g_bytesGeschrieben += bufsize; // measurement: does any scan data arrive at all? int i = g_spurIdx; g_spurLba[i] = lba; g_spurN[i] = bufsize / (sec ? sec : 512); g_spurT[i] = millis(); g_spurIdx = (i + 1) % SPUR_ANZAHL; @@ -491,9 +491,9 @@ static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufs return ergebnis; } -// Karte dem Host entziehen und warten, bis wirklich niemand mehr darauf zugreift. -// Liefert false, wenn ein Zugriff nach 5 s immer noch laeuft - dann lieber -// nicht anfassen und den Host behalten lassen. +// Take the card away from the host and wait until really nobody accesses it. +// Returns false if an access is still running after 5 s - then better not +// touch it and let the host keep it. static bool karteUebernehmen() { MSC.mediaPresent(false); @@ -503,7 +503,7 @@ static bool karteUebernehmen() if (g_usbZugriffe == 0 && millis() - g_lastHost > 300) return true; delay(10); } - logZeile(String("[usb] Host laesst nicht los (") + (int)g_usbZugriffe + " Zugriffe offen)"); + logZeile(String("[usb] host does not let go (") + (int)g_usbZugriffe + " accesses open)"); g_sdGesperrt = false; MSC.mediaPresent(true); return false; @@ -515,13 +515,13 @@ static void karteZurueckgeben() MSC.mediaPresent(true); } -// Neustart, der auch am Drucker ueberlebt. Der 780 schaltet dem USB-Port kurz -// den Strom ab, sobald sich ein Geraet abmeldet. Startet der Stick sofort neu, -// trifft dieser Stromschnitt die gerade erst gebootete Firmware, bevor sie sich -// beim Bootloader als gueltig gemeldet hat - und der faellt auf die vorige -// zurueck (Rollback ist im Core eingeschaltet). Zweimal blieb so v27 stehen, -// obwohl v28 sauber eingespielt war. Deshalb: erst am USB abmelden, den -// Stromschnitt in der ALTEN Firmware abwarten, dann neu starten. +// A restart that survives at the printer too. The 780 briefly cuts the power to +// the USB port as soon as a device unregisters. If the stick restarts at once, +// this power cut hits the firmware that has only just booted, before it has +// reported itself as valid to the bootloader - and that one falls back to the +// previous one (rollback is switched on in the core). Twice v27 stayed this way +// although v28 had been installed cleanly. Therefore: unregister from USB first, +// wait out the power cut in the OLD firmware, then restart. static void sanftNeustarten() { logFlushNetz(); @@ -531,9 +531,9 @@ static void sanftNeustarten() ESP.restart(); } -// SCSI START STOP UNIT. Geraete senden das oft am Jobende ("auswerfen", -// "Puffer rausschreiben"). Wenn der Drucker das tut, haben wir ein sofortiges -// Fertig-Signal und muessen nicht 45 s auf Stille warten. +// SCSI START STOP UNIT. Devices often send this at the end of a job ("eject", +// "flush the buffer"). If the printer does that, we have an immediate +// done signal and do not have to wait 45 s for silence. static volatile uint32_t g_letztesStop = 0; static volatile bool g_stopNeu = false; static volatile bool g_stopStart = false; @@ -542,8 +542,8 @@ static volatile uint8_t g_stopPc = 0; static bool onStartStop(uint8_t pc, bool start, bool eject) { - // Bewusst nichts Aufwendiges hier: der Rueckruf laeuft im USB-Zusammenhang, - // Zeichenketten und Dateizugriffe haben hier nichts verloren. + // Deliberately nothing expensive here: the callback runs in the USB context, + // strings and file access have no business in this place. g_letztesStop = millis(); g_stopStart = start; g_stopEject = eject; @@ -552,30 +552,30 @@ static bool onStartStop(uint8_t pc, bool start, bool eject) return true; } -// ---- Diagnose-Log: sammelt im RAM, geht per WLAN raus ---- -// BEWUSST nicht auf die SD: solange der Drucker das Medium hat, wuerde jeder -// eigene FAT-Write seinen noch offenen Commit zerstoeren - also genau den -// Vorgang, den wir beobachten wollen. Der USB-CDC reisst beim WLAN-Start ab, -// deshalb ist das Netz der einzige verlaessliche Kanal. +// ---- Diagnostic log: collected in RAM, goes out over WiFi ---- +// DELIBERATELY not onto the SD: as long as the printer holds the medium, any +// FAT write of our own would destroy its still open commit - that is exactly the +// operation we want to observe. The USB CDC drops away when WiFi starts, +// so the network is the only reliable channel. static String g_logPuffer; -static size_t g_logGesendet = 0; // bis hierhin ist der Puffer schon beim Empfaenger +static size_t g_logGesendet = 0; // up to here the buffer has already reached the receiver static void logZeile(const String &msg) { Serial.println(msg); g_logPuffer += String(millis()) + " " + msg + "\n"; - if (g_logPuffer.length() > 8000) { // Deckel, RAM ist knapp + if (g_logPuffer.length() > 8000) { // cap, RAM is tight g_logPuffer.remove(0, 4000); g_logGesendet = g_logGesendet > 4000 ? g_logGesendet - 4000 : 0; } } -// Geraeteauthentifizierung: HMAC-SHA256 ueber Name, Kennung und Laenge mit dem -// Geraeteschluessel, als Kopfzeile X-Scan-Auth. Der Empfaenger weist alles ohne -// gueltige Signatur ab. Einen Zeitstempel braucht es nicht: eine Wiederholung -// desselben Uploads ist unschaedlich, der Empfaenger dedupliziert per Kennung. -// Kein TLS - im LAN reicht das, und ein TLS-Kontext kostet auf dem Stick rund -// 40 kB Arbeitsspeicher und jeden Upload spuerbar Zeit. +// Device authentication: HMAC-SHA256 over name, id and length with the +// device key, as header X-Scan-Auth. The receiver rejects everything without +// a valid signature. A timestamp is not needed: a repeat of the +// same upload is harmless, the receiver deduplicates by id. +// No TLS - on the LAN that is enough, and a TLS context costs the stick about +// 40 kB of memory and every upload a noticeable amount of time. static String uploadSignatur(const String &name, const String &id, uint32_t laenge) { if (cfgSchluessel.isEmpty()) return ""; @@ -589,10 +589,10 @@ static String uploadSignatur(const String &name, const String &id, uint32_t laen return hex; } -// Nur das Neue seit dem letzten Mal wegschicken. Beruehrt die SD-Karte nicht. -// Vorher ging jedes Mal der ganze Puffer raus - pro Scan eine Protokolldatei -// mit der gesamten Vorgeschichte. Der Puffer selbst bleibt stehen, die -// Weboberflaeche soll den ganzen Verlauf zeigen. +// Send away only what is new since the last time. Does not touch the SD card. +// Before, the whole buffer went out every time - one log file per scan +// with the entire history. The buffer itself stays in place, the +// web interface is meant to show the full history. static void logFlushNetz() { if (g_logGesendet >= g_logPuffer.length() || cfgEndpoint.isEmpty()) return; @@ -612,8 +612,8 @@ static void logFlushNetz() if (code >= 200 && code < 300) g_logGesendet = g_logPuffer.length(); } -// ---- MBR-Partitionstyp auf FAT32-LBA (0x0C) setzen ---- -// ESP-Format hinterlaesst manchmal 0x07 (macOS liest das als NTFS und mountet nicht) +// ---- Set the MBR partition type to FAT32-LBA (0x0C) ---- +// ESP format sometimes leaves 0x07 behind (macOS reads that as NTFS and will not mount) static void fixMbrTyp() { uint8_t *s = (uint8_t *)heap_caps_malloc(512, MALLOC_CAP_DMA); @@ -621,34 +621,34 @@ static void fixMbrTyp() if (sdLesen(s, 0) && s[510] == 0x55 && s[511] == 0xAA) { if (s[446 + 4] == 0x07) { s[446 + 4] = 0x0C; - if (sdSchreiben(s, 0)) Serial.println("[mbr] Typ 0x07 -> 0x0C korrigiert"); + if (sdSchreiben(s, 0)) Serial.println("[mbr] type 0x07 -> 0x0C corrected"); } } free(s); } -// ================= Das Verzeichnis roh mitlesen ================= -// Bisher musste der Stick die Karte kurz abhaengen und wieder anhaengen, um zu -// sehen, was der Drucker geschrieben hat - in diesen Millisekunden ist sie fuer -// den Drucker weg. Deshalb durfte er nur selten nachsehen und musste auf Stille -// warten. Liest er die Sektoren dagegen selbst, stoert er niemanden und darf -// jede Sekunde schauen. +// ================= Reading along in the directory, raw ================= +// Until now the stick had to unmount the card briefly and mount it again to +// see what the printer had written - during those milliseconds it is gone for +// the printer. That is why it was only allowed to look rarely and had to wait +// for silence. If it reads the sectors itself instead, it disturbs nobody and +// may look every second. // -// FAT32 kurz: Bootsektor (Layout) -> Zuordnungstabelle (welcher Cluster gehoert -// zu welcher Datei) -> Verzeichnis (32 Byte je Eintrag mit Name und GROESSE). -// Die endgueltige Groesse traegt der Schreiber erst beim Schliessen ein - genau -// daran erkennen wir, dass eine Datei fertig ist. - -#define ROH_MIN_GROESSE 2048 // darunter: Hilfsdateien wie wifi.cfg, kein Scan -#define MAX_FUND 24 // so viele Dateien je Durchlauf - -// Was gilt als Scan? EIN Massstab fuer den Rohleser (sieht nur den 8.3-Kurznamen) -// und den Sammler (sieht den langen Namen). Vorher zaehlte der Rohleser jede -// Datei ab 2 kB, der Sammler uebersprang aber alles mit fuehrendem Punkt. Eine -// "._wifi.cfg" vom Mac (4 kB, Kurzname "_WIFI~1.CFG") war roh sichtbar, im -// Sammellauf aber nicht - der Stick meldete das Medium daraufhin fuenfmal in -// Folge ab und wieder an, nach jedem Start und nach jedem Schreibzugriff. -static bool istScanEndung(const uint8_t *e) // 3 Zeichen aus dem Kurznamen, gross +// FAT32 in brief: boot sector (layout) -> allocation table (which cluster belongs +// to which file) -> directory (32 bytes per entry with name and SIZE). +// The writer only enters the final size on closing - that is exactly +// how we recognize that a file is done. + +#define ROH_MIN_GROESSE 2048 // below that: helper files like wifi.cfg, no scan +#define MAX_FUND 24 // this many files per pass + +// What counts as a scan? ONE yardstick for the raw reader (sees only the 8.3 short name) +// and for the collector (sees the long name). Before, the raw reader counted every +// file from 2 kB up, but the collector skipped everything with a leading dot. A +// "._wifi.cfg" from the Mac (4 kB, short name "_WIFI~1.CFG") was visible raw, but +// not in the collecting pass - upon which the stick unmounted and remounted the +// medium five times in a row, after every start and after every write. +static bool istScanEndung(const uint8_t *e) // 3 characters from the short name, uppercase { return !memcmp(e, "PDF", 3) || !memcmp(e, "JPG", 3) || !memcmp(e, "JPE", 3) || !memcmp(e, "TIF", 3); @@ -669,11 +669,11 @@ struct FatLage { uint32_t fatStart; uint32_t ersterDatenSektor; uint32_t rootCluster; - uint32_t partStart; // fuer die Selbstpruefung und das Formatieren + uint32_t partStart; // for the self-check and for formatting uint32_t partSektoren; uint32_t fatSektoren; uint8_t anzahlFats; - uint32_t gesamtCluster; // Datencluster (Nummern 2 .. gesamt+1) + uint32_t gesamtCluster; // data clusters (numbers 2 .. total+1) uint16_t fsInfoSektor; }; static FatLage g_fat = { false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; @@ -691,7 +691,7 @@ static void fatLageLesen() uint32_t partStart = 0, partSektoren = 0; if (sdLesen(s, 0) && s[510] == 0x55 && s[511] == 0xAA) { - partStart = le32(s + 446 + 8); // Startsektor der ersten Partition + partStart = le32(s + 446 + 8); // start sector of the first partition partSektoren = le32(s + 446 + 12); } if (!sdLesen(s, partStart)) { free(s); return; } @@ -734,8 +734,8 @@ static uint32_t fatNaechster(uint32_t c, uint8_t *puffer) return le32(puffer + (versatz % 512)) & 0x0FFFFFFF; } -// Zaehlt Dateien im Wurzelverzeichnis und summiert ihre Groessen. -// Bleiben Anzahl und Summe ueber mehrere Blicke gleich, schreibt niemand mehr. +// Counts files in the root directory and sums up their sizes. +// If count and sum stay the same over several looks, nobody is writing any more. static bool rohVerzeichnis(int &anzahl, uint32_t &summe) { anzahl = 0; @@ -754,14 +754,14 @@ static bool rohVerzeichnis(int &anzahl, uint32_t &summe) if (!sdLesen(sek, clusterSektor(cl) + i)) { fertig = true; break; } for (int e = 0; e < 512; e += 32) { uint8_t *d = sek + e; - if (d[0] == 0x00) { fertig = true; break; } // Ende des Verzeichnisses - if (d[0] == 0xE5) continue; // geloeschter Eintrag + if (d[0] == 0x00) { fertig = true; break; } // end of the directory + if (d[0] == 0xE5) continue; // deleted entry uint8_t attr = d[11]; - if (attr == 0x0F) continue; // Teil eines langen Namens - if (attr & 0x18) continue; // Ordner oder Datentraegername - if (!istScanEndung(d + 8)) continue; // Endung im Kurznamen: Byte 8-10 + if (attr == 0x0F) continue; // part of a long name + if (attr & 0x18) continue; // folder or volume label + if (!istScanEndung(d + 8)) continue; // extension in the short name: byte 8-10 uint32_t gr = le32(d + 28); - if (gr < ROH_MIN_GROESSE) continue; // Hilfsdatei, kein Scan + if (gr < ROH_MIN_GROESSE) continue; // helper file, no scan anzahl++; summe += gr; } @@ -773,18 +773,18 @@ static bool rohVerzeichnis(int &anzahl, uint32_t &summe) return true; } -// Erster Cluster eines Verzeichniseintrags (Bytes 20/21 hoch, 26/27 tief) +// First cluster of a directory entry (bytes 20/21 high, 26/27 low) static uint32_t eintragCluster(const uint8_t *d) { return ((uint32_t)(d[20] | (d[21] << 8)) << 16) | (uint32_t)(d[26] | (d[27] << 8)); } -// Einen Eintrag in einem Verzeichnis roh als geloescht markieren (erstes Byte -// 0xE5), OHNE die Blockzuordnung anzufassen. Lange Namen liegen in Stuecken zu -// 13 Zeichen VOR dem Kurzeintrag, in umgekehrter Reihenfolge; auch die werden -// markiert. Gesucht wird per Name (wenn angegeben) oder per Startcluster. -// Nur bei abgemeldetem Medium und VOR dem naechsten Mount aufrufen, sonst -// schreibt der Dateisystem-Treiber seinen gepufferten Sektor wieder darueber. +// Mark an entry in a directory as deleted, raw (first byte +// 0xE5), WITHOUT touching the block allocation. Long names sit in pieces of +// 13 characters BEFORE the short entry, in reverse order; those are marked +// as well. The search goes by name (if given) or by start cluster. +// Only call with the medium unmounted and BEFORE the next mount, otherwise +// the file system driver writes its buffered sector over it again. static bool rohEintragLoeschenIn(uint32_t dirCluster, const String &name, uint32_t groesse, uint32_t startCluster) { if (!g_fat.gueltig) fatLageLesen(); @@ -823,7 +823,7 @@ static bool rohEintragLoeschenIn(uint32_t dirCluster, const String &name, uint32 continue; } String kandidat = lang; - if (kandidat.isEmpty()) { // nur Kurzname: "NAME EXT" + if (kandidat.isEmpty()) { // short name only: "NAME EXT" for (int k = 0; k < 8 && d[k] != ' '; k++) kandidat += (char)d[k]; if (d[8] != ' ') { kandidat += '.'; for (int k = 8; k < 11 && d[k] != ' '; k++) kandidat += (char)d[k]; } } @@ -833,12 +833,12 @@ static bool rohEintragLoeschenIn(uint32_t dirCluster, const String &name, uint32 d[0] = 0xE5; for (int k = 0; k < lfnAnzahl; k++) if (lfnSektor[k] == sektor) sek[lfnOffset[k]] = 0xE5; getroffen = sdSchreiben(sek, sektor); - for (int k = 0; k < lfnAnzahl && getroffen; k++) { // Stuecke in frueheren Sektoren + for (int k = 0; k < lfnAnzahl && getroffen; k++) { // pieces in earlier sectors if (lfnSektor[k] == sektor) continue; if (!sdLesen(sek, lfnSektor[k])) { getroffen = false; break; } for (int m = 0; m < lfnAnzahl; m++) if (lfnSektor[m] == lfnSektor[k]) sek[lfnOffset[m]] = 0xE5; if (!sdSchreiben(sek, lfnSektor[k])) getroffen = false; - sektor = lfnSektor[k]; // diese Gruppe ist erledigt + sektor = lfnSektor[k]; // this group is done } break; } @@ -853,8 +853,8 @@ static bool rohEintragLoeschenIn(uint32_t dirCluster, const String &name, uint32 return getroffen; } -// Cluster eines Unterordners der Wurzel, per 8.3-Name (11 Zeichen, gross, -// mit Leerzeichen aufgefuellt). 0 = nicht vorhanden. +// Cluster of a subfolder of the root, by 8.3 name (11 characters, uppercase, +// padded with spaces). 0 = not present. static uint32_t rohOrdnerCluster(const char *name83) { if (!g_fat.gueltig) fatLageLesen(); @@ -882,8 +882,8 @@ static uint32_t rohOrdnerCluster(const char *name83) return ergebnis; } -// Startcluster und Groessen aller Dateien eines Verzeichnisses roh einsammeln. -// Liefert die Anzahl. dirCluster 0 = Wurzel. +// Collect start clusters and sizes of all files of a directory, raw. +// Returns the count. dirCluster 0 = root. static int rohEintraege(uint32_t dirCluster, uint32_t *starts, uint32_t *groessen, int max, bool nurScans) { if (!g_fat.gueltig) fatLageLesen(); @@ -914,12 +914,12 @@ static int rohEintraege(uint32_t dirCluster, uint32_t *starts, uint32_t *groesse return n; } -// Geisterjagd in der Wurzel - roh, bei abgemeldetem Medium, VOR dem Mount. -// Der 780 schreibt nach dem Wiederanmelden seine alte Verzeichnissicht zurueck: -// der vorige Scan liegt dann erneut als [Untitled].pdf in der Wurzel und zeigt -// auf Bloecke, die entweder schon einer Datei in /senden oder /gesendet -// gehoeren oder laengst frei sind. Solche Eintraege werden nur ausgetragen, -// die Blockzuordnung bleibt unangetastet. Liefert die Zahl der Geister. +// Ghost hunt in the root - raw, with the medium unmounted, BEFORE the mount. +// After remounting, the 780 writes its old view of the directory back: +// the previous scan then sits in the root again as [Untitled].pdf and points +// at blocks that either already belong to a file in /senden or /gesendet +// or have long been free. Such entries are merely struck out, +// the block allocation stays untouched. Returns the number of ghosts. static int geisterJagen() { uint32_t fremd[64]; @@ -941,31 +941,31 @@ static int geisterJagen() for (int i = 0; i < nWurzel; i++) { uint32_t st = wurzel[i]; const char *grund = nullptr; - if (st < 2 || fatNaechster(st, fatBuf) == 0) grund = "zeigt auf freie Bloecke"; + if (st < 2 || fatNaechster(st, fatBuf) == 0) grund = "points at free blocks"; for (int k = 0; !grund && k < nFremd; k++) - if (fremd[k] == st) grund = "zeigt auf Bloecke einer schon weggeraeumten Datei"; + if (fremd[k] == st) grund = "points at blocks of a file already cleared away"; for (int k = 0; !grund && k < nWurzel; k++) { if (k == i || wurzel[k] != st) continue; - // Zwei Wurzeleintraege auf denselben Bloecken: der Geist ist der, - // dessen Groesse nicht zur Kettenlaenge passt. + // Two root entries on the same blocks: the ghost is the one + // whose size does not match the chain length. uint32_t n = 0, c = st; while (c >= 2 && c < 0x0FFFFFF8 && n < 300000) { n++; c = fatNaechster(c, fatBuf); } uint32_t passt = (wurzelGr[i] + clusterBytes - 1) / clusterBytes; - if (passt != n) grund = "teilt Bloecke mit einem anderen Eintrag und passt nicht zur Kette"; + if (passt != n) grund = "shares blocks with another entry and does not match the chain"; } if (!grund) continue; bool ok = rohEintragLoeschenIn(0, "", wurzelGr[i], st); - logZeile(String("[geist] Wurzeleintrag mit ") + (wurzelGr[i] / 1024) + " kB " + grund + - (ok ? " - ausgetragen" : " - austragen fehlgeschlagen")); + logZeile(String("[geist] root entry with ") + (wurzelGr[i] / 1024) + " kB " + grund + + (ok ? " - struck out" : " - striking out failed")); if (ok) geister++; } free(fatBuf); return geister; } -// ---- Rohes Lesen ganzer Dateien und die Merkliste (siehe Ablauf ab v26 unten) ---- +// ---- Raw reading of whole files and the watch list (see the flow from v26 below) ---- -// Verzeichnis roh listen, mit langen Namen. dirCluster 0 = Wurzel. +// List a directory raw, with long names. dirCluster 0 = root. static int rohListe(uint32_t dirCluster, RohEintrag *liste, int max, bool nurScans) { if (!g_fat.gueltig) fatLageLesen(); @@ -1020,7 +1020,7 @@ static int rohListe(uint32_t dirCluster, RohEintrag *liste, int max, bool nurSca return n; } -// Absoluter Sektor Nummer sektorIndex einer Datei ab Startcluster, 0 = Fehler +// Absolute sector number sektorIndex of a file from its start cluster, 0 = error static uint32_t rohSektor(uint32_t start, uint32_t sektorIndex, uint8_t *fatBuf) { uint32_t cl = start; @@ -1032,7 +1032,7 @@ static uint32_t rohSektor(uint32_t start, uint32_t sektorIndex, uint8_t *fatBuf) return clusterSektor(cl) + sektorIndex % g_fat.sektorenProCluster; } -// Fertig geschrieben? Bei PDF steht %%EOF in den letzten 1024 Bytes - roh gelesen. +// Finished writing? With a PDF, %%EOF sits in the last 1024 bytes - read raw. static bool rohVollstaendig(const RohEintrag &e) { String n = e.name; @@ -1058,7 +1058,7 @@ static bool rohVollstaendig(const RohEintrag &e) return false; } -// Kennzahl aus Groesse, ersten 256 und letzten bis zu 256 Bytes - roh gelesen +// Fingerprint from size, first 256 and last up to 256 bytes - read raw static uint32_t rohKennzahl(const RohEintrag &e) { uint8_t *sek = (uint8_t *)heap_caps_malloc(512, MALLOC_CAP_DMA); @@ -1077,7 +1077,7 @@ static uint32_t rohKennzahl(const RohEintrag &e) for (uint32_t i = im > 256 ? im - 256 : 0; i < im; i++) summe = summe * 31u + sek[i]; } } - if (e.groesse > 2048) { // und ein Stueck aus der Mitte (Bilddaten) + if (e.groesse > 2048) { // and a piece from the middle (image data) sk = rohSektor(e.start, e.groesse / 1024, fatBuf); if (sk && sdLesen(sek, sk)) for (uint32_t i = 0; i < 256; i++) summe = summe * 31u + sek[i]; @@ -1087,7 +1087,7 @@ static uint32_t rohKennzahl(const RohEintrag &e) return summe; } -// Liest eine Datei roh entlang der Belegungskette, sektorweise +// Reads a file raw along the allocation chain, sector by sector struct RohLeser { uint32_t groesse = 0, pos = 0, cluster = 0; uint8_t idx = 0; @@ -1116,12 +1116,12 @@ struct RohLeser { void ende() { if (sek) free(sek); if (fatBuf) free(fatBuf); sek = fatBuf = nullptr; } }; -// ---- Merkliste: gesendet, liegt noch auf der Karte ---- +// ---- Memo list: sent, still lying on the card ---- #define MAX_FERTIG 64 #define MAX_GELOESCHT 16 static Fertig g_fertig[MAX_FERTIG]; static int g_fertigAnzahl = 0; -static Fertig g_geloescht[MAX_GELOESCHT]; // Signaturen weggeraeumter Dateien: so erkennt man ihre Geister +static Fertig g_geloescht[MAX_GELOESCHT]; // Signatures of cleared-away files: that is how their ghosts are spotted static int g_geloeschtAnzahl = 0; static void fertigSpeichern() @@ -1142,7 +1142,7 @@ static void fertigLaden() n = g_nvs.getBytesLength("geloescht"); if (n && n <= sizeof g_geloescht && n % sizeof(Fertig) == 0) { g_nvs.getBytes("geloescht", g_geloescht, n); g_geloeschtAnzahl = n / sizeof(Fertig); } g_nvs.end(); - if (g_fertigAnzahl) logZeile(String("[merk] ") + g_fertigAnzahl + " gesendete Datei(en) liegen laut Flash noch auf der Karte"); + if (g_fertigAnzahl) logZeile(String("[merk] ") + g_fertigAnzahl + " sent file(s) are still on the card according to flash"); } static int fertigIndex(uint32_t start, uint32_t groesse) @@ -1157,11 +1157,11 @@ static bool istGeloescht(uint32_t start, uint32_t groesse) return false; } -// Startblock und Groesse allein reichen NICHT: nach dem Aufraeumen ist die Karte -// leer, der naechste Scan landet auf demselben Startblock, und zweimal dasselbe -// Blatt hat dieselbe Groesse. Am 20.09.2026 galt so eine neue Datei als Geist -// und wurde nie gesendet. Erst die Kennzahl aus dem Inhalt unterscheidet die -// Wiederkehr einer alten Datei von einer neuen an derselben Stelle. +// Start block and size alone are NOT enough: after the cleanup the card is +// empty, the next scan lands on the same start block, and the same sheet +// twice has the same size. On 20.09.2026 such a new file counted as a ghost +// and was never sent. Only the checksum taken from the content tells the +// return of an old file apart from a new one in the same place. static int fertigIndexE(const RohEintrag &e) { int i = fertigIndex(e.start, e.groesse); @@ -1182,9 +1182,9 @@ static void fertigMerken(const RohEintrag &e, uint32_t kennzahl, const String &s int alt = fertigIndex(e.start, e.groesse); if (alt >= 0 && g_fertig[alt].kennzahl == kennzahl) return; if (alt >= 0) { - // Gleicher Startblock, gleiche Groesse, anderer Inhalt: der Eintrag gehoert - // jetzt der neuen Datei. Bliebe die alte Kennzahl stehen, gaelte die neue - // Datei bei jedem Blick wieder als neu und wuerde endlos hochgeladen. + // Same start block, same size, different content: the entry now belongs + // to the new file. If the old checksum stayed, the new file would count + // as new on every look and would be uploaded endlessly. geloeschtMerken(g_fertig[alt]); memmove(g_fertig + alt, g_fertig + alt + 1, (g_fertigAnzahl - alt - 1) * sizeof(Fertig)); g_fertigAnzahl--; @@ -1202,10 +1202,10 @@ static void geloeschtMerken(const Fertig &f) g_geloescht[g_geloeschtAnzahl++] = f; } -// Liegt in der Wurzel ein Scan, der weder gesendet noch als Geist bekannt ist? -// Liegt in der Wurzel etwas, das noch zu senden ist? Geister zaehlen nicht: -// Eintraege auf freie Bloecke oder auf die Bloecke eines anderen Eintrags sind -// Sache des Aufraeumfensters - sonst drehte sich der Waechter an ihnen im Kreis. +// Is there a scan in the root that is neither sent nor known as a ghost? +// Is there anything in the root still to be sent? Ghosts do not count: +// entries pointing at free blocks or at the blocks of another entry are +// business of the cleanup window - else the watcher would circle on them. static bool rohOffen() { RohEintrag liste[MAX_FUND]; @@ -1224,7 +1224,7 @@ static bool rohOffen() return offen; } -// Was in der Wurzel noch NICHT gesendet ist - fuer die Anzeige. +// What in the root is NOT yet sent - for the display. static void rohOffenSumme(int &anzahl, uint32_t &summe) { anzahl = 0; summe = 0; @@ -1238,12 +1238,12 @@ static void rohOffenSumme(int &anzahl, uint32_t &summe) } -// ---- Selbstpruefung der Karte: ein kleines fsck auf den rohen Sektoren ---- -// Der 780 schreibt seine alte Verzeichnissicht zurueck und bricht Jobs ab. Was -// dabei liegen bleibt - doppelt belegte Bloecke, verwaiste Ketten, zu kurze -// Ketten, die Schmutzmarke - liess ihn am 20.09.2026 die Karte ganz ablehnen -// ("USB-Stick anschliessen"). Laeuft nur, wenn der Drucker die Karte nicht -// sieht: beim Start vor dem Anmelden und im Aufraeumfenster. +// ---- Card self-check: a small fsck on the raw sectors ---- +// The 780 writes its old directory view back and aborts jobs. What is left +// behind - doubly used blocks, orphaned chains, chains that are too short, +// the dirty flag - made it reject the card completely on 20.09.2026 +// ("connect USB stick"). Runs only when the printer does not see the card: +// at start before attaching, and in the cleanup window. static KartenBefund g_befund = {}; static uint32_t g_befundZeit = 0; static int g_heillosFolge = 0; @@ -1252,7 +1252,7 @@ static inline bool bitDa(const uint8_t *bits, uint32_t c) { return bits[c >> 3] static inline void bitSetzen(uint8_t *bits, uint32_t c) { bits[c >> 3] |= (1 << (c & 7)); } static inline void bitLoeschen(uint8_t *bits, uint32_t c) { bits[c >> 3] &= ~(1 << (c & 7)); } -// Einen Tabelleneintrag in beide Tabellen schreiben (obere 4 Bit bleiben) +// Write one table entry into both tables (the upper 4 bits stay) static bool fatSetzen(uint32_t c, uint32_t wert, uint8_t *fatBuf) { uint32_t versatz = c * 4, sek = versatz / 512, off = versatz % 512; @@ -1264,10 +1264,10 @@ static bool fatSetzen(uint32_t c, uint32_t wert, uint8_t *fatBuf) return ok; } -// Kette ab 'start' in der Belegungskarte markieren. sollCluster > 0: so lang darf -// die Datei hoechstens sein, dahinter wird die Kette abgeschnitten. Liefert die -// Anzahl markierter Cluster. kreuz: ein Cluster gehoerte schon jemand anderem. -// kaputt: die Kette lief ins Leere oder war zu lang (bei 'heilen' repariert). +// Mark the chain from 'start' in the allocation map. sollCluster > 0: that is +// the maximum length of the file, beyond it the chain is cut off. Returns the +// number of marked clusters. kreuz: a cluster already belonged to someone else. +// kaputt: the chain ran into nothing or was too long (repaired on 'heilen'). static uint32_t ketteMarkieren(uint32_t start, uint32_t sollCluster, uint8_t *bits, uint8_t *fatBuf, bool heilen, bool &kreuz, bool &kaputt) { @@ -1286,8 +1286,8 @@ static uint32_t ketteMarkieren(uint32_t start, uint32_t sollCluster, uint8_t *bi if (n > g_fat.gesamtCluster) { kaputt = true; break; } c = next; } - if (c >= 0x0FFFFFF8) return n; // sauberes Ende - kaputt = true; // 0, reserviert oder ausserhalb + if (c >= 0x0FFFFFF8) return n; // clean end + kaputt = true; // 0, reserved or out of range if (heilen && vorher) fatSetzen(vorher, 0x0FFFFFFF, fatBuf); return n; } @@ -1301,9 +1301,9 @@ static void ketteEntmarkieren(uint32_t start, uint32_t n, uint8_t *bits, uint8_t } } -// Ein Verzeichnis ablaufen: jede Datei und jeden Unterordner markieren, Schaeden -// zaehlen und bei 'heilen' beheben. Bei Doppelbelegung gewinnt der zuerst -// gefundene Eintrag - die Wurzel, also die Datei des Druckers, kommt zuerst. +// Walk one directory: mark every file and every subfolder, count the damage +// and fix it on 'heilen'. On a double claim the entry found first wins - +// the root, that is the printer's file, comes first. static bool pruefeVerzeichnis(uint32_t dirCluster, int tiefe, uint8_t *bits, uint8_t *sek, uint8_t *fatBuf, bool heilen, KartenBefund &b) { @@ -1321,17 +1321,17 @@ static bool pruefeVerzeichnis(uint32_t dirCluster, int tiefe, uint8_t *bits, uin if (d[0] == 0x00) { if (geaendert) sdSchreiben(sek, sektor); return true; } if (d[0] == 0xE5) { lfnN = 0; continue; } if (d[11] == 0x0F) { if (lfnN < 20) { lfnSektor[lfnN] = sektor; lfnOffset[lfnN] = e; lfnN++; } continue; } - if ((d[11] & 0x08) || d[0] == '.') { lfnN = 0; continue; } // Datentraegername, . und .. + if ((d[11] & 0x08) || d[0] == '.') { lfnN = 0; continue; } // volume label, . and .. uint32_t start = eintragCluster(d), groesse = le32(d + 28); bool istOrdner = d[11] & 0x10; - if (start < 2) { lfnN = 0; continue; } // leere Datei + if (start < 2) { lfnN = 0; continue; } // empty file bool kreuz, kaputt; uint32_t soll = istOrdner ? 0 : (groesse + clusterBytes - 1) / clusterBytes; uint32_t n = ketteMarkieren(start, soll, bits, fatBuf, heilen, kreuz, kaputt); if (kreuz) { b.kreuz++; if (heilen) { - ketteEntmarkieren(start, n, bits, fatBuf); // was frei bleibt, raeumt der Waisenlauf + ketteEntmarkieren(start, n, bits, fatBuf); // what stays free is cleared by the orphan pass d[0] = 0xE5; for (int k = 0; k < lfnN; k++) { if (lfnSektor[k] == sektor) { sek[lfnOffset[k]] = 0xE5; continue; } @@ -1346,7 +1346,7 @@ static bool pruefeVerzeichnis(uint32_t dirCluster, int tiefe, uint8_t *bits, uin } } else { if (kaputt) { b.gekuerzt++; if (heilen) b.geaendert = true; } - if (!istOrdner && n < soll) { // Kette kuerzer als die Groesse + if (!istOrdner && n < soll) { // chain shorter than the size if (!kaputt) b.gekuerzt++; if (heilen) { uint32_t neu = n * clusterBytes; @@ -1372,20 +1372,20 @@ static bool pruefeVerzeichnis(uint32_t dirCluster, int tiefe, uint8_t *bits, uin static String befundText(const KartenBefund &b) { - if (b.zuGross) return "Partition zu gross fuer die Pruefung"; - if (b.heillos) return "Dateisystem nicht lesbar"; + if (b.zuGross) return "partition too large for the check"; + if (b.heillos) return "file system not readable"; String t; - if (b.fatAbweichungen) t += String(b.fatAbweichungen) + " Tabellensektor(en) abweichend, "; - if (b.kreuz) t += String(b.kreuz) + " Kreuzverkettung(en), "; - if (b.verwaist) t += String(b.verwaist) + " verwaiste Cluster, "; - if (b.gekuerzt) t += String(b.gekuerzt) + " Kette(n) gekuerzt, "; - if (b.schmutzig) t += "Schmutzmarke, "; - if (t.isEmpty()) return "in Ordnung"; + if (b.fatAbweichungen) t += String(b.fatAbweichungen) + " table sector(s) differing, "; + if (b.kreuz) t += String(b.kreuz) + " cross-link(s), "; + if (b.verwaist) t += String(b.verwaist) + " orphaned clusters, "; + if (b.gekuerzt) t += String(b.gekuerzt) + " chain(s) shortened, "; + if (b.schmutzig) t += "dirty flag, "; + if (t.isEmpty()) return "in order"; t.remove(t.length() - 2); - return t + (b.geaendert ? " - behoben" : ""); + return t + (b.geaendert ? " - fixed" : ""); } -// Die eigentliche Pruefung. Nur aufrufen, wenn der Host die Karte nicht sieht. +// The actual check. Only call it when the host does not see the card. static void kartePruefen(bool heilen, const char *anlass) { uint32_t t0 = millis(); @@ -1396,14 +1396,14 @@ static void kartePruefen(bool heilen, const char *anlass) b.heillos = true; g_heillosFolge++; g_befund = b; g_befundZeit = millis(); - logZeile(String("[karte] ") + anlass + ": Dateisystem nicht lesbar (" + g_heillosFolge + ". Mal in Folge)"); + logZeile(String("[karte] ") + anlass + ": file system not readable (" + g_heillosFolge + " time(s) in a row)"); return; } uint32_t bytes = (g_fat.gesamtCluster + 2 + 7) / 8; if (bytes > 65536) { b.zuGross = true; g_befund = b; g_befundZeit = millis(); - logZeile(String("[karte] ") + anlass + ": " + g_fat.gesamtCluster + " Cluster - zu gross fuer die Pruefung, Partition kleiner machen"); + logZeile(String("[karte] ") + anlass + ": " + g_fat.gesamtCluster + " clusters - too large for the check, make the partition smaller"); return; } uint8_t *bits = (uint8_t *)calloc(bytes, 1); @@ -1411,11 +1411,11 @@ static void kartePruefen(bool heilen, const char *anlass) uint8_t *fatBuf = (uint8_t *)heap_caps_malloc(512, MALLOC_CAP_DMA); if (!bits || !sek || !fatBuf) { if (bits) free(bits); if (sek) free(sek); if (fatBuf) free(fatBuf); - logZeile("[karte] kein Speicher fuer die Pruefung"); + logZeile("[karte] no memory for the check"); return; } - // 1. Beide Tabellen vergleichen - die erste gilt + // 1. Compare both tables - the first one counts if (g_fat.anzahlFats > 1) { for (uint32_t x = 0; x < g_fat.fatSektoren; x++) { if (!sdLesen(sek, g_fat.fatStart + x) || !sdLesen(fatBuf, g_fat.fatStart + g_fat.fatSektoren + x)) break; @@ -1426,14 +1426,14 @@ static void kartePruefen(bool heilen, const char *anlass) } } - // 2. Wurzel und alles darunter ablaufen + // 2. Walk the root and everything below it bool kreuz, kaputt; ketteMarkieren(g_fat.rootCluster, 0, bits, fatBuf, heilen, kreuz, kaputt); if (kaputt) { b.gekuerzt++; if (heilen) b.geaendert = true; } if (!pruefeVerzeichnis(g_fat.rootCluster, 0, bits, sek, fatBuf, heilen, b)) b.heillos = true; - // 3. Verwaiste Cluster: belegt, aber von niemandem erreicht. Nebenbei die - // freien zaehlen - der Freizaehler im FSInfo-Sektor wird gleich mit gerichtet. + // 3. Orphaned clusters: in use, but reached by nobody. Count the free ones + // on the way - the free counter in the FSInfo sector is fixed along with it. uint32_t frei = 0, ersterFrei = 0; if (!b.heillos) { for (uint32_t x = 0; x < g_fat.fatSektoren; x++) { @@ -1463,10 +1463,10 @@ static void kartePruefen(bool heilen, const char *anlass) } } - // 4. Schmutzmarke, zweifach: Bit 27 "sauber ausgehaengt" und Bit 26 "keine - // Fehler" im zweiten Tabelleneintrag (Windows), und Bit 0 im Byte 65 des - // Bootsektors (Linux setzt es beim Einhaengen, loescht es beim Aushaengen; - // faellt das Medium vorher weg, bleibt es stehen - fsck: "Dirty bit is set"). + // 4. Dirty flag, twice over: bit 27 "cleanly unmounted" and bit 26 "no + // errors" in the second table entry (Windows), and bit 0 in byte 65 of + // the boot sector (Linux sets it on mounting, clears it on unmounting; + // if the medium drops out before that it stays - fsck: "Dirty bit is set"). if (!b.heillos && sdLesen(sek, g_fat.fatStart)) { uint32_t v = le32(sek + 4); if ((v & 0x0C000000) != 0x0C000000) { @@ -1484,7 +1484,7 @@ static void kartePruefen(bool heilen, const char *anlass) if (heilen) { sek[65] &= ~0x01; if (sdSchreiben(sek, g_fat.partStart)) b.geaendert = true; - uint16_t sicherung = (uint16_t)sek[50] | ((uint16_t)sek[51] << 8); // Sicherungskopie des Bootsektors + uint16_t sicherung = (uint16_t)sek[50] | ((uint16_t)sek[51] << 8); // backup copy of the boot sector if (sicherung && sicherung < 32) sdSchreiben(sek, g_fat.partStart + sicherung); } } @@ -1495,10 +1495,10 @@ static void kartePruefen(bool heilen, const char *anlass) free(bits); free(sek); free(fatBuf); } -// Letzte Stufe: die Partition frisch als FAT32 anlegen (32-kB-Cluster wie -// mkfs.vfat -s 64). Nur, wenn nichts Ungesendetes mehr drauf ist - die -// Einstellungen liegen im Flash, auf der Karte geht nichts verloren, was nicht -// schon beim Empfaenger waere. +// Last resort: lay out the partition freshly as FAT32 (32 kB clusters like +// mkfs.vfat -s 64). Only when nothing unsent is left on it - the settings +// live in flash, nothing is lost on the card that would not already be at +// the receiver. static bool karteFormatieren(const char *anlass) { uint8_t *s = (uint8_t *)heap_caps_malloc(512, MALLOC_CAP_DMA); @@ -1508,28 +1508,28 @@ static bool karteFormatieren(const char *anlass) partStart = le32(s + 446 + 8); partSektoren = le32(s + 446 + 12); } uint32_t karte = SD_MMC.sectorSize() ? (uint32_t)(SD_MMC.cardSize() / SD_MMC.sectorSize()) : 0; - if (!partStart || !partSektoren || partStart + partSektoren > karte) { free(s); logZeile("[karte] Formatieren: keine brauchbare Partition"); return false; } + if (!partStart || !partSektoren || partStart + partSektoren > karte) { free(s); logZeile("[karte] format: no usable partition"); return false; } const uint8_t spc = 64; const uint16_t rsv = 32; const uint8_t nfat = 2; uint32_t fatSek = 0; - for (int i = 0; i < 4; i++) { // Tabellengroesse einpendeln + for (int i = 0; i < 4; i++) { // settle the table size uint32_t cl = (partSektoren - rsv - nfat * fatSek) / spc; fatSek = ((cl + 2) * 4 + 511) / 512; } uint32_t cluster = (partSektoren - rsv - nfat * fatSek) / spc; - if (cluster < 65525) { free(s); logZeile("[karte] Formatieren: Partition zu klein fuer FAT32"); return false; } - logZeile(String("[karte] formatiere (") + anlass + "): " + (partSektoren / 2048) + " MB, " + cluster + " Cluster"); + if (cluster < 65525) { free(s); logZeile("[karte] format: partition too small for FAT32"); return false; } + logZeile(String("[karte] formatting (") + anlass + "): " + (partSektoren / 2048) + " MB, " + cluster + " clusters"); uint32_t t0 = millis(); bool ok = true; - // Tabellen leeren + // Clear the tables memset(s, 0, 512); for (uint32_t x = 0; x < nfat * fatSek && ok; x++) ok = sdSchreiben(s, partStart + rsv + x); - // Wurzel leeren (ein Cluster) + // Clear the root (one cluster) for (uint32_t x = 0; x < spc && ok; x++) ok = sdSchreiben(s, partStart + rsv + nfat * fatSek + x); - // Ersten Tabellensektor: Medienbyte, sauber-Marken, Wurzel-Ende + // First table sector: media byte, clean flags, root end const uint8_t kopf[12] = { 0xF8, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0x0F }; memset(s, 0, 512); memcpy(s, kopf, 12); for (int f = 0; f < nfat && ok; f++) ok = sdSchreiben(s, partStart + rsv + f * fatSek); - // Bootsektor + // Boot sector memset(s, 0, 512); const uint8_t bs[] = { 0xEB, 0x58, 0x90, 'M','S','W','I','N','4','.','1' }; memcpy(s, bs, sizeof(bs)); @@ -1555,40 +1555,40 @@ static bool karteFormatieren(const char *anlass) free(s); g_fat.gueltig = false; g_fertigAnzahl = 0; g_geloeschtAnzahl = 0; fertigSpeichern(); - logZeile(String("[karte] formatiert in ") + (millis() - t0) + " ms" + (ok ? "" : " - MIT FEHLERN")); + logZeile(String("[karte] formatted in ") + (millis() - t0) + " ms" + (ok ? "" : " - WITH ERRORS")); return ok; } -#define AUFRAEUM_VORGABE 3 // Minuten ohne Zugriff des Druckers (der 780 fasst den Stick zwischen Jobs gar nicht an) +#define AUFRAEUM_VORGABE 3 // Minutes without printer access (the 780 does not touch the stick between jobs at all) static uint32_t g_aufraeumMin = AUFRAEUM_VORGABE; static bool g_aufraeumJetzt = false; static uint32_t g_letztesAufraeumen = 0; -// ---- Konfiguration von der SD lesen ---- -// Die Datei gilt nur, wenn sie sich seit dem letzten Uebernehmen GEAENDERT hat. -// Vorher gewann sie bei jedem Start - wer das Upload-Ziel in der Weboberflaeche -// umstellte, bekam beim naechsten Neustart still den alten Wert aus der Datei -// zurueck. Der Pruefstand ist genau daran gescheitert. Liefert true, wenn die -// Werte uebernommen wurden. +// ---- Read the configuration from the SD card ---- +// The file only counts when it has CHANGED since it was last taken over. +// Before, it won at every start - whoever switched the upload target in the +// web interface silently got the old value from the file back at the next +// restart. The test rig failed on exactly that. Returns true when the +// values were taken over. static bool ladeConfig() { File f = SD_MMC.open("/wifi.cfg"); - if (!f) { logZeile("[cfg] /wifi.cfg fehlt"); return false; } + if (!f) { logZeile("[cfg] /wifi.cfg missing"); return false; } String inhalt = f.readString(); f.close(); - uint32_t stand = 2166136261u; // FNV-1a ueber den Dateiinhalt + uint32_t stand = 2166136261u; // FNV-1a over the file content for (unsigned i = 0; i < inhalt.length(); i++) { stand ^= (uint8_t)inhalt[i]; stand *= 16777619u; } g_nvs.begin("scanstick", true); uint32_t bekannt = g_nvs.getUInt("cfgstand", 0); g_nvs.end(); if (stand == bekannt) { - logZeile("[cfg] /wifi.cfg unveraendert - Einstellungen aus dem Flash gelten"); + logZeile("[cfg] /wifi.cfg unchanged - the settings from flash count"); return false; } - // Jede ssid=-Zeile beginnt ein neues Netz, pass= gehoert zur letzten ssid. + // Every ssid= line starts a new network, pass= belongs to the last ssid. String neuSsid[MAX_NETZE], neuPass[MAX_NETZE]; int neu = 0; int von = 0; @@ -1616,42 +1616,42 @@ static bool ladeConfig() g_nvs.end(); String liste; for (int i = 0; i < cfgNetze; i++) liste += (i ? ", " : "") + cfgNetzSsid[i]; - logZeile(String("[cfg] /wifi.cfg neu uebernommen: Netze=") + liste + " endpoint=" + cfgEndpoint); + logZeile(String("[cfg] /wifi.cfg newly taken over: networks=") + liste + " endpoint=" + cfgEndpoint); return true; } -// Warum sind wir gestartet? Ohne diese Zeile ist ein Neustart im Betrieb nicht -// von einem Stromausfall zu unterscheiden - und Brownout, Absturz und Watchdog -// verlangen voellig verschiedene Gegenmassnahmen. -static const char *g_startGrund = "unbekannt"; +// Why did we start? Without this line a restart during operation cannot be +// told apart from a power failure - and brownout, crash and watchdog each +// demand completely different countermeasures. +static const char *g_startGrund = "unknown"; static const char *resetGrund() { switch (esp_reset_reason()) { - case ESP_RST_POWERON: return "Strom eingeschaltet"; - case ESP_RST_SW: return "Software-Neustart"; - case ESP_RST_PANIC: return "Absturz (Panic)"; - case ESP_RST_INT_WDT: return "Interrupt-Watchdog"; - case ESP_RST_TASK_WDT: return "Task-Watchdog"; - case ESP_RST_WDT: return "Watchdog"; - case ESP_RST_BROWNOUT: return "Brownout (Spannungseinbruch)"; - case ESP_RST_DEEPSLEEP: return "Tiefschlaf"; - case ESP_RST_EXT: return "externer Reset"; - case ESP_RST_USB: return "USB-Reset"; - default: return "unbekannt"; - } -} - -// ---- Zugangsdaten zusaetzlich im NVS-Flash ---- -// Sie stehen auf der SD (/wifi.cfg). Mountet die Karte nicht, gaebe es ohne -// Kopie im Flash kein WLAN und keine Weboberflaeche - also genau dann keine -// Diagnose, wenn man sie braucht. Darum spiegeln. + case ESP_RST_POWERON: return "power switched on"; + case ESP_RST_SW: return "software restart"; + case ESP_RST_PANIC: return "crash (panic)"; + case ESP_RST_INT_WDT: return "interrupt watchdog"; + case ESP_RST_TASK_WDT: return "task watchdog"; + case ESP_RST_WDT: return "watchdog"; + case ESP_RST_BROWNOUT: return "brownout (voltage dip)"; + case ESP_RST_DEEPSLEEP: return "deep sleep"; + case ESP_RST_EXT: return "external reset"; + case ESP_RST_USB: return "USB reset"; + default: return "unknown"; + } +} + +// ---- Credentials also kept in the NVS flash ---- +// They live on the SD card (/wifi.cfg). If the card does not mount, without a +// copy in flash there would be no WiFi and no web interface - that is, no +// diagnostics exactly when you need them. So we mirror them. static void cfgAusNvs() { g_nvs.begin("scanstick", true); - // Netze als Zeilen "ssidpass". Aeltere Staende kennen nur ssid/pass. + // Networks as lines "ssidpass". Older versions only know ssid/pass. String netze = g_nvs.getString("netze", ""); cfgNetze = 0; - if (netze.isEmpty() && !g_nvs.isKey("netze")) { // nur wenn es die Liste noch nie gab: alte Einzelwerte + if (netze.isEmpty() && !g_nvs.isKey("netze")) { // only if the list never existed: old single values String s = g_nvs.getString("ssid", ""); if (s.length()) { cfgNetzSsid[0] = s; cfgNetzPass[0] = g_nvs.getString("pass", ""); cfgNetze = 1; } } else { @@ -1674,7 +1674,7 @@ static void cfgAusNvs() cfgSchluessel = g_nvs.getString("schluessel", ""); g_idleMs = g_nvs.getUInt("idle", IDLE_VORGABE); g_aufraeumMin = g_nvs.getUInt("aufraeum", AUFRAEUM_VORGABE); - g_loeschen = true; // "nach /gesendet verschieben" gibt es nicht mehr (Kreuzverkettungen, 20.09.2026) + g_loeschen = true; // "move to /gesendet" no longer exists (cross-linked clusters, 20.09.2026) g_invertiert = g_nvs.getBool("invers", true); g_ledHell = (uint8_t)g_nvs.getUChar("ledhell", 5); for (int i = 0; i < Z_ANZAHL; i++) { @@ -1685,7 +1685,7 @@ static void cfgAusNvs() g_farbeSendet = g_nvs.getUInt("fsendet", g_farbeSendet); g_farbeFertig = g_nvs.getUInt("ffertig", g_farbeFertig); g_nvs.end(); - if (cfgNetze) logZeile(String("[nvs] ") + cfgNetze + " Netz(e) aus dem Flash, erstes: " + cfgNetzSsid[0]); + if (cfgNetze) logZeile(String("[nvs] ") + cfgNetze + " network(s) from flash, first: " + cfgNetzSsid[0]); } static void cfgNachNvs() @@ -1694,7 +1694,7 @@ static void cfgNachNvs() String netze; for (int i = 0; i < cfgNetze; i++) netze += cfgNetzSsid[i] + "\t" + cfgNetzPass[i] + "\n"; g_nvs.putString("netze", netze); - g_nvs.remove("ssid"); g_nvs.remove("pass"); // alte Einzelwerte: sonst kehrt ein entferntes Netz zurueck + g_nvs.remove("ssid"); g_nvs.remove("pass"); // old single values: otherwise a removed network comes back g_nvs.putString("endpoint", cfgEndpoint); g_nvs.putString("webpass", cfgWebPass); g_nvs.putString("praefix", cfgPraefix); @@ -1714,26 +1714,26 @@ static void cfgNachNvs() g_nvs.end(); } -// WLAN nur anstossen, nicht auf die Verbindung warten: der Drucker soll den -// Stick sofort als Laufwerk sehen, nicht erst nach dem WLAN-Timeout. -static String g_apKennung; // Kennung des gewaehlten Zugangspunkts +// Only kick WiFi off, do not wait for the connection: the printer should see +// the stick as a drive at once, not only after the WiFi timeout. +static String g_apKennung; // BSSID of the chosen access point static int g_apKanal = 0; static long g_apRssi = 0; -static uint32_t g_wlanVerloren = 0; // seit wann ohne Netz (0 = verbunden) -#define WLAN_NEUSUCHE 120000 // so lange ohne Netz, dann neu suchen - -// Mehrere Zugangspunkte koennen dieselbe SSID tragen (einzelne APs, kein Mesh), -// und der Stick kennt mehrere Netze. WiFiMulti sucht ueber alle bekannten Netze -// hinweg, nimmt den staerksten Zugangspunkt und verbindet gezielt mit dessen -// Kennung und Kanal. Blockiert bis zur Verbindung oder WIFI_TIMEOUT - deshalb -// laeuft das erst, wenn der Drucker den Stick schon als Laufwerk sieht. +static uint32_t g_wlanVerloren = 0; // since when without a network (0 = connected) +#define WLAN_NEUSUCHE 120000 // this long without a network, then search again + +// Several access points can carry the same SSID (single APs, no mesh), and the +// stick knows several networks. WiFiMulti searches across all known networks, +// takes the strongest access point and connects to it deliberately by its +// BSSID and channel. Blocks until connected or WIFI_TIMEOUT - which is why +// this only runs once the printer already sees the stick as a drive. // -// Die feste Kennung hat eine Kehrseite: faellt genau dieser Zugangspunkt aus, -// versucht der Auto-Reconnect nur ihn. Darum sucht loop() nach zwei Minuten -// ohne Netz von vorn - dann darf es auch ein anderer Zugangspunkt sein. +// The fixed BSSID has a downside: if exactly that access point fails, the +// auto-reconnect only tries that one. So loop() searches from scratch after +// two minutes without a network - then another access point is allowed too. static void wlanStarten() { - if (!cfgNetze) { logZeile("[wifi] kein Netz bekannt"); return; } + if (!cfgNetze) { logZeile("[wifi] no network known"); return; } WiFi.mode(WIFI_STA); WiFi.setHostname(geraeteName().c_str()); g_wifiMulti.APlistClean(); @@ -1744,11 +1744,11 @@ static void wlanStarten() g_apKanal = WiFi.channel(); g_apRssi = WiFi.RSSI(); g_wlanVerloren = 0; - logZeile(String("[wifi] ") + WiFi.SSID() + " ueber " + g_apKennung + " auf Kanal " + - g_apKanal + " mit " + g_apRssi + " dBm, IP " + WiFi.localIP().toString()); + logZeile(String("[wifi] ") + WiFi.SSID() + " via " + g_apKennung + " on channel " + + g_apKanal + " with " + g_apRssi + " dBm, IP " + WiFi.localIP().toString()); } else { g_apKennung = ""; - logZeile("[wifi] kein bekanntes Netz erreichbar"); + logZeile("[wifi] no known network reachable"); } } @@ -1759,12 +1759,12 @@ static bool wifiVerbinden() return WiFi.status() == WL_CONNECTED; } -// ---- Einrichtung ohne Karte im Lesegeraet ---- -// Kennt der Stick kein Netz oder erreicht er keins, spannt er ein eigenes WLAN -// auf (Name = Geraetename, offen) und beantwortet jede Namensanfrage mit sich -// selbst - Handy oder Rechner oeffnen dann von selbst die Einrichtungsseite, -// wie im Hotel. Sobald ein bekanntes Netz erreichbar ist, geht das WLAN wieder -// aus. Der Scanpfad ist davon unberuehrt: USB laeuft, Uploads warten nur. +// ---- Setup without a card in the reader ---- +// If the stick knows no network or reaches none, it opens a WiFi of its own +// (name = device name, open) and answers every name lookup with itself - +// phone or computer then open the setup page by themselves, like in a hotel. +// As soon as a known network is reachable, that WiFi goes off again. The scan +// path is untouched by this: USB runs, uploads merely wait. static DNSServer g_dns; static bool g_einrichtung = false; static bool g_einrichtungGezeichnet = false; @@ -1780,16 +1780,16 @@ static void zeigeEinrichtung() g_gfx->setTextColor(COL_BLACK); g_gfx->setTextSize(2); g_gfx->setCursor(4, 4); - g_gfx->print("WLAN EINRICHTEN"); + g_gfx->print("WIFI SETUP"); g_gfx->setTextSize(1); g_gfx->setCursor(4, 30); - g_gfx->print("Mit diesem Netz verbinden:"); + g_gfx->print("Connect to this network:"); g_gfx->setTextSize(2); g_gfx->setCursor(4, 42); g_gfx->print(geraeteName()); g_gfx->setTextSize(1); g_gfx->setCursor(4, 66); - g_gfx->print("dann http://192.168.4.1/"); + g_gfx->print("then http://192.168.4.1/"); } static void einrichtungStarten(const char *grund) @@ -1801,7 +1801,7 @@ static void einrichtungStarten(const char *grund) g_dns.start(53, "*", WiFi.softAPIP()); g_einrichtung = true; g_einrichtungGezeichnet = false; - logZeile(String("[einrichtung] ") + grund + " - eigenes WLAN \"" + geraeteName() + "\" offen, Seite http://" + + logZeile(String("[einrichtung] ") + grund + " - own WiFi \"" + geraeteName() + "\" open, page http://" + WiFi.softAPIP().toString() + "/"); } @@ -1813,13 +1813,13 @@ static void einrichtungBeenden() WiFi.mode(WIFI_STA); g_einrichtung = false; g_screen = -1; - logZeile("[einrichtung] Netz gefunden - eigenes WLAN wieder aus"); + logZeile("[einrichtung] network found - own WiFi off again"); } -// http://host[:port]/pfad zerlegen +// split http://host[:port]/path static bool urlTeile(const String &url, String &host, uint16_t &port, String &pfad) { - if (!url.startsWith("http://")) return false; // bewusst nur http, kein TLS auf dem Stick + if (!url.startsWith("http://")) return false; // deliberately http only, no TLS on the stick String rest = url.substring(7); int sl = rest.indexOf('/'); String hostteil = (sl < 0) ? rest : rest.substring(0, sl); @@ -1830,13 +1830,13 @@ static bool urlTeile(const String &url, String &host, uint16_t &port, String &pf return host.length() > 0; } -// Upload in Bloecken. Wir schreiben HTTP selbst, weil HTTPClient die Datei in -// einem Zug schluckt und keinen Fortschritt meldet - den braucht das Display, -// und bei schwachem WLAN sieht man so ueberhaupt, ob es vorangeht oder haengt. -// ---- Uhrzeit per NTP ---- -// Nur fuer Dateinamen: "Untitled_7.pdf" ist im Ablageziel wertlos, -// "scan-20260919-1432.pdf" sortiert sich von selbst. -#define ZEIT_WIEDERHOLUNG 120000 // alle 2 Minuten erneut versuchen, bis es klappt +// Upload in blocks. We write HTTP ourselves because HTTPClient swallows the +// file in one go and reports no progress - the display needs it, and on weak +// WiFi it is the only way to see whether things move at all or hang. +// ---- Clock via NTP ---- +// Only for file names: "Untitled_7.pdf" is worthless in the filing target, +// "scan-20260919-1432.pdf" sorts itself. +#define ZEIT_WIEDERHOLUNG 120000 // try again every 2 minutes until it works static uint32_t g_zeitVersuch = 0; static void zeitHolen() @@ -1850,11 +1850,11 @@ static void zeitHolen() strftime(s, sizeof s, "%d.%m.%Y %H:%M:%S", &t); logZeile(String("[zeit] ") + s); } else { - logZeile("[zeit] keine NTP-Antwort - Dateinamen bekommen die Laufzeit"); + logZeile("[zeit] no NTP reply - file names get the uptime"); } } -// Eindeutiger Name mit Zeitstempel, Endung bleibt erhalten. +// Unique name with time stamp, the extension is kept. static String neuerName(const String &alt) { String endung; @@ -1863,12 +1863,12 @@ static String neuerName(const String &alt) char stempel[32]; struct tm t; if (g_zeitOk && getLocalTime(&t, 200)) strftime(stempel, sizeof stempel, "%Y%m%d-%H%M%S", &t); - else snprintf(stempel, sizeof stempel, "nach%lus", (unsigned long)(millis() / 1000)); + else snprintf(stempel, sizeof stempel, "after%lus", (unsigned long)(millis() / 1000)); return cfgPraefix + "-" + stempel + endung; } -// Fuer Links: Klammern, Leerzeichen und Umlaute muessen kodiert werden, -// sonst zeigt der Download-Link ins Leere. +// For links: brackets, spaces and umlauts have to be encoded, otherwise the +// download link points nowhere. static String urlKodiert(const String &s) { String r; @@ -1880,14 +1880,14 @@ static String urlKodiert(const String &s) return r; } -// Ist die Datei fertig geschrieben? Bei PDF steht das in der Datei selbst: -// sie endet mit "%%EOF". Das ist ein Beweis - anders als Stille oder Dateigroesse, -// denn der 780 traegt die endgueltige Groesse schon vor dem Schreiben ein und -// reserviert den Platz. Ohne diese Pruefung haben wir eine halb geschriebene -// Datei hochgeladen, deren hinterer Teil nur aus Leerbytes bestand. +// Is the file written completely? For PDF the file itself says so: it ends +// with "%%EOF". That is proof - unlike silence or file size, because the 780 +// enters the final size before writing and reserves the space. Without this +// check we have uploaded a half-written file whose tail consisted only of +// empty bytes. static bool dateiVollstaendig(const String &pfad, uint32_t groesse) { - if (!pfad.endsWith(".pdf") && !pfad.endsWith(".PDF")) return true; // nur PDF pruefbar + if (!pfad.endsWith(".pdf") && !pfad.endsWith(".PDF")) return true; // only PDF can be checked if (groesse < 32) return false; File f = SD_MMC.open(pfad); if (!f) return false; @@ -1902,14 +1902,14 @@ static bool dateiVollstaendig(const String &pfad, uint32_t groesse) return false; } -// Kennung der Datei fuer den Empfaenger. Der Drucker stellt nach dem -// Verschieben gern seine alte Verzeichnissicht wieder her - dann zeigt -// "[Untitled].pdf" erneut auf dieselben Daten und wir laden sie ein zweites -// Mal hoch. Der Empfaenger erkennt die Wiederholung an dieser Kennung. +// Identifier of the file for the receiver. After a move the printer likes to +// restore its old directory view - then "[Untitled].pdf" points at the same +// data again and we upload it a second time. The receiver recognises the +// repeat by this identifier. // -// Kurze Kennzahl aus Groesse plus erstem und letztem Block - reicht, um -// dieselbe Datei wiederzuerkennen, und kostet kaum Lesezeit. Zum LOESCHEN ohne -// Upload taugt sie bewusst nicht (siehe sendeGefundene). +// Short check number from size plus first and last block - enough to recognise +// the same file again, and it costs hardly any read time. For DELETING without +// an upload it deliberately does not suffice (see sendeGefundene). static uint32_t dateiKennzahl(const String &pfad, uint32_t groesse) { File f = SD_MMC.open(pfad); @@ -1929,37 +1929,37 @@ static uint32_t dateiKennzahl(const String &pfad, uint32_t groesse) #define UP_FEHLER 0 #define UP_OK 1 -#define UP_DUPLIKAT 2 // angenommen, aber der Empfaenger kannte die Datei schon -// Upload aus einer beliebigen Quelle: lese(puffer, n) liefert Bytes, 0 = Ende, -// negativ = Fehler. Datei per Dateisystem oder roh entlang der Belegungskette. +#define UP_DUPLIKAT 2 // accepted, but the receiver already knew the file +// Upload from any source: lese(puffer, n) delivers bytes, 0 = end, negative = +// error. File via the file system or raw along the allocation chain. static int ladeHochQuelle(const String &name, const String &id, uint32_t len, std::function lese) { - if (!len) { logZeile("[up] " + name + " ist leer, uebersprungen"); return UP_FEHLER; } + if (!len) { logZeile("[up] " + name + " is empty, skipped"); return UP_FEHLER; } String host, ziel; uint16_t port; if (!urlTeile(cfgEndpoint, host, port, ziel)) { - logZeile("[up] Ziel-Adresse unbrauchbar: " + cfgEndpoint); + logZeile("[up] target address unusable: " + cfgEndpoint); return UP_FEHLER; } ziel += (ziel.indexOf('?') < 0) ? "?name=" : "&name="; ziel += name; - // Eindeutige Kennung derselben Datei. Damit kann der Empfaenger doppelte - // Uebertragungen erkennen und verwerfen - etwa wenn ein Upload abbricht und - // spaeter wiederholt wird, oder wenn der Drucker seine alte Verzeichnissicht - // zurueckschreibt und die Datei dadurch erneut auftaucht. + // Unique identifier of the same file. With it the receiver can spot and + // discard duplicate transfers - for instance when an upload breaks off and + // is repeated later, or when the printer writes back its old directory + // view and the file shows up again because of that. if (id.length()) { ziel += "&id="; ziel += id; } - // Bei schwachem Funk scheitert der erste Verbindungsaufbau gern mal. - // Einmal aufgeben hiess bisher: Datei bleibt liegen, naechster Anlauf erst - // beim naechsten Scan. + // On weak radio the first connection attempt likes to fail. Giving up once + // used to mean: the file stays put, the next try only comes with the next + // scan. WiFiClient c; bool verbunden = false; for (int v = 1; v <= 3 && !verbunden; v++) { verbunden = c.connect(host.c_str(), port); if (!verbunden) { - logZeile(String("[up] keine Verbindung zu ") + host + ":" + port + - " (Versuch " + v + " von 3)"); + logZeile(String("[up] no connection to ") + host + ":" + port + + " (attempt " + v + " of 3)"); delay(1500); } } @@ -1976,19 +1976,19 @@ static int ladeHochQuelle(const String &name, const String &id, uint32_t len, uint8_t puffer[1024]; uint32_t geschickt = 0, t0 = millis(); while (geschickt < len) { - // Der Drucker schreibt den naechsten Scan: dann jetzt nicht von der Karte - // lesen. Vor v26 war das Medium waehrend des Uploads weg, seit v26 lesen - // wir daneben - und genau in dieser Ueberlappung brach der 780 zweimal - // mit 44.12.05 ab. Also abbrechen und nachholen, sobald er fertig ist; - // ein doppelt angekommener Upload ist beim Empfaenger ein Duplikat. - if ((int32_t)(g_lastWrite - t0) > 0) { logZeile("[up] Drucker schreibt - Upload abgebrochen, wird nachgeholt"); break; } + // The printer is writing the next scan: then do not read from the card + // now. Before v26 the medium was gone during the upload, since v26 we + // read alongside - and exactly in that overlap the 780 aborted twice + // with 44.12.05. So abort and catch up once it is done; an upload that + // arrived twice is a duplicate at the receiver. + if ((int32_t)(g_lastWrite - t0) > 0) { logZeile("[up] printer is writing - upload aborted, will be caught up"); break; } int gelesen = lese(puffer, sizeof puffer); - if (gelesen <= 0) { logZeile("[up] Karte liefert keine Daten mehr"); break; } + if (gelesen <= 0) { logZeile("[up] card delivers no more data"); break; } int raus = c.write(puffer, gelesen); - if (raus != gelesen) { logZeile("[up] Verbindung brach beim Senden ab"); break; } + if (raus != gelesen) { logZeile("[up] connection broke while sending"); break; } geschickt += raus; zeigeSendenFortschritt(geschickt, len); - if (millis() - t0 > 180000) { logZeile("[up] Zeitueberschreitung beim Senden"); break; } + if (millis() - t0 > 180000) { logZeile("[up] timeout while sending"); break; } } if (geschickt != len) { c.stop(); return UP_FEHLER; } @@ -2001,38 +2001,38 @@ static int ladeHochQuelle(const String &name, const String &id, uint32_t len, String zeile = c.readStringUntil('\n'); // "HTTP/1.1 200 OK" int sp = zeile.indexOf(' '); if (sp > 0) code = zeile.substring(sp + 1, sp + 4).toInt(); - // Rest der Antwort: sagt der Empfaenger "Duplikat", kannte er die Datei - // schon - dann ist unser Eintrag ein Geist (siehe sendeGefundene). + // Rest of the response: if the receiver says "duplicate" (or the older + // "Duplikat"), it already knew the file - our entry is a ghost (see sendeGefundene). uint32_t tr = millis(); while ((c.connected() || c.available()) && millis() - tr < 3000) { if (!c.available()) { delay(10); continue; } String z = c.readStringUntil('\n'); - if (z.indexOf("Duplikat") >= 0) duplikat = true; + if (z.indexOf("Duplikat") >= 0 || z.indexOf("uplicate") >= 0) duplikat = true; } } c.stop(); if (WiFi.status() == WL_CONNECTED) { g_rssiLetztLast = WiFi.RSSI(); rssiErfassen(g_rssiLetztLast); } logZeile(String("[up] ") + name + " " + (geschickt / 1024) + " kB HTTP " + code + - (duplikat ? " (Duplikat)" : "") + " in " + ((millis() - t0) / 1000) + " s"); + (duplikat ? " (duplicate)" : "") + " in " + ((millis() - t0) / 1000) + " s"); if (code >= 200 && code < 300) { zeigeFertig(geschickt); delay(1200); return duplikat ? UP_DUPLIKAT : UP_OK; } return UP_FEHLER; } -// Upload per Dateisystem (fuer Reste in /senden aus v25) +// Upload via the file system (for leftovers in /senden from v25) static int ladeHoch(fs::FS &fs, const String &pfad, const String &name, const String &id) { File f = fs.open(pfad); - if (!f) { logZeile("[up] " + pfad + " nicht oeffenbar"); return UP_FEHLER; } + if (!f) { logZeile("[up] " + pfad + " cannot be opened"); return UP_FEHLER; } uint32_t len = f.size(); int erg = ladeHochQuelle(name, id, len, [&](uint8_t *b, size_t n) { return f.read(b, n); }); f.close(); return erg; } -// ================= Weboberflaeche ================= -// Der Stick steckt im Drucker: kein Serial (der CDC gehoert im MSC-Betrieb dem -// TinyUSB-Stack), Display nur fuer den, der davorsteht. Die Webseite ist der -// einzige Kanal, der von ueberall offen ist. +// ================= Web interface ================= +// The stick sits in the printer: no Serial (in MSC mode the CDC belongs to the +// TinyUSB stack), display only for whoever stands in front of it. The web page +// is the only channel that is open from anywhere. static String menschlich(uint64_t b) { @@ -2056,8 +2056,8 @@ static String dauer(uint32_t ms) return String(t); } -// Die letzten Schreibzugriffe des Hosts als Text: fuer die Statusseite (html) -// und fuers Protokoll, wenn ein Zyklus nichts hochgeladen hat. +// The host's last write accesses as text: for the status page (html) and for +// the log, when a cycle has uploaded nothing. static String spurText(bool html) { String spur; @@ -2067,15 +2067,15 @@ static String spurText(bool html) uint32_t lba = g_spurLba[i]; const char *wo = !g_fat.gueltig ? "?" : lba < g_fat.fatStart ? "Boot" : lba < g_fat.ersterDatenSektor ? "FAT" - : lba < clusterSektor(g_fat.rootCluster) + g_fat.sektorenProCluster ? "Wurzel" : "Daten"; - if (html) spur += String("vor ") + dauer(millis() - g_spurT[i]) + ": " + lba + " +" + g_spurN[i] + " (" + wo + ")
"; + : lba < clusterSektor(g_fat.rootCluster) + g_fat.sektorenProCluster ? "Root" : "Data"; + if (html) spur += String("") + dauer(millis() - g_spurT[i]) + " ago: " + lba + " +" + g_spurN[i] + " (" + wo + ")
"; else spur += String(lba) + "+" + g_spurN[i] + wo[0] + " "; } return spur; } -// Die Seite gibt gescannte Post zum Herunterladen frei. Ohne Passwort kann -// jedes Geraet im WLAN mitlesen - deshalb Basic-Auth, sobald eines gesetzt ist. +// The page hands out scanned mail for download. Without a password every +// device on the WiFi can read along - hence Basic Auth as soon as one is set. static bool webAuth() { if (cfgWebPass.isEmpty()) return true; @@ -2093,7 +2093,7 @@ static String hexFarbe(uint32_t rgb) static String htmlKopf(const String &titel) { - return String("" + return String("" "" "Scan-Stick

") + titel + "

" - "