From 2e3768b4b2f9ab6b0a355ffd3d68a4d115e205a0 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Fri, 16 Oct 2026 18:41:33 +0200 Subject: [PATCH 1/5] Isolate Chromium profiles in browser smoke tests --- tests/js/browser-smoke.test.js | 36 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/js/browser-smoke.test.js b/tests/js/browser-smoke.test.js index bc24b04..81318da 100644 --- a/tests/js/browser-smoke.test.js +++ b/tests/js/browser-smoke.test.js @@ -20,19 +20,27 @@ function chromiumCommand() { } function runChromium(command, url) { - return spawnSync(command, [ - "--headless", - "--disable-gpu", - "--disable-dev-shm-usage", - "--no-sandbox", - "--allow-file-access-from-files", - "--dump-dom", - "--virtual-time-budget=3000", - url, - ], { - encoding: "utf8", - timeout: 20000, - }); + const profile = fs.mkdtempSync(path.join(os.tmpdir(), "kassa-chromium-")); + try { + return spawnSync(command, [ + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--headless", + "--disable-gpu", + "--disable-dev-shm-usage", + "--no-sandbox", + "--allow-file-access-from-files", + "--dump-dom", + "--virtual-time-budget=3000", + url, + ], { + encoding: "utf8", + timeout: 40000, + }); + } finally { + fs.rmSync(profile, {recursive: true, force: true}); + } } function skipIfChromiumSandboxed(t, result) { @@ -103,6 +111,7 @@ test("kassa index boots in a real browser", (t) => { fs.unlinkSync(fixturePath); if(skipIfChromiumSandboxed(t, result)) return; + assert.ifError(result.error); assert.equal(result.status, 0, result.stderr); const match = result.stdout.match(/
([^<]+)<\/pre>/);
   assert(match, result.stdout);
@@ -148,6 +157,7 @@ test("kassa frontend posts keyboard and button input in a browser", (t) => {
   fs.unlinkSync(fixturePath);
   if(skipIfChromiumSandboxed(t, result)) return;
 
+  assert.ifError(result.error);
   assert.equal(result.status, 0, result.stderr);
   const match = result.stdout.match(/
([^<]+)<\/pre>/);
   assert(match, result.stdout);

From ad5f65ecc0dfc711c5c3a8b6730edc225db53449 Mon Sep 17 00:00:00 2001
From: Mendel Mobach 
Date: Fri, 16 Oct 2026 18:45:39 +0200
Subject: [PATCH 2/5] Print configurable Epson receipt header images

---
 README.md                 | 23 +++++++++++++++
 config.py                 |  4 +++
 config.yaml.example       |  5 ++++
 plugins/POS.py            | 42 +++++++++++++++++++++++++---
 tests/plugins/test_POS.py | 59 +++++++++++++++++++++++++++++++++++++++
 5 files changed, 129 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 9b6f4ce..a702e01 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,29 @@ You need python, paho-mqtt-client and apache+php.
 
 To install the python dependencies, run 'pip install -r requirements.txt'.
 
+### Receipt header image
+
+The Epson receipt header defaults to `images/hack42.png`. Configure another
+PNG/JPEG file and maximum width in printer dots in `config.yaml`:
+
+```yaml
+pos:
+  header:
+    image: images/hack42.png
+    width: 384
+```
+
+Relative image paths resolve from the project directory. Transparent pixels
+print white; the image is scaled down proportionally and converted to monochrome.
+The header is sent with each new receipt, stock overview, declaration, and party
+settlement. Existing saved receipts retain their original header when reprinted.
+Set `image: ""` to keep using the logo stored in the printer. If the configured
+file cannot be loaded, a warning is logged and the stored logo is used.
+
+This uses Epson's [GS v 0 raster command](https://download4.epson.biz/sec_pubs/pos/reference_en/escpos/gs_lv_0.html),
+which must be supported by the printer model. It does not change the printer's
+nonvolatile logo memory. Set `width` to at most your printer's printable width.
+
 ### data
 
 Data is stored in 'data'. In production it is also committed to git, but for
diff --git a/config.py b/config.py
index 8cb4909..83eb2fc 100644
--- a/config.py
+++ b/config.py
@@ -28,6 +28,10 @@
         },
     },
     "pos": {
+        "header": {
+            "image": "images/hack42.png",
+            "width": 384,
+        },
         "serial": {
             "port": "/dev/ttyUSB0",
             "baudrate": 19200,
diff --git a/config.yaml.example b/config.yaml.example
index 4be949e..423c1a6 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -22,6 +22,11 @@ stickers:
     # identifier: tcp://BRW008092D4A414.space.hack42.nl:9100
 
 pos:
+  header:
+    # Relative to the project directory, or an absolute path.
+    # Set image to "" to use the logo already stored in the printer.
+    image: images/hack42.png
+    width: 384
   serial:
     port: /dev/ttyUSB0
     baudrate: 19200
diff --git a/plugins/POS.py b/plugins/POS.py
index c9920a4..e503385 100644
--- a/plugins/POS.py
+++ b/plugins/POS.py
@@ -4,9 +4,12 @@
 import json
 import logging
 import pickle
+from pathlib import Path
+import struct
 import time
 
 import serial
+from PIL import Image, ImageOps
 
 from config import config_get
 
@@ -67,8 +70,39 @@ def help(self):
             "printstock": "Print stock overview",
         }
 
+    def header_image(self):
+        settings = config_get("pos", "header", default={})
+        filename = settings.get("image", "")
+        if not filename:
+            return LOGO
+        try:
+            width = int(settings.get("width", 384))
+            if not 8 <= width <= 2048:
+                raise ValueError("Header width must be between 8 and 2048 dots")
+            path = Path(filename)
+            if not path.is_absolute():
+                path = Path(__file__).resolve().parent.parent / path
+            with Image.open(path) as source:
+                rgba = source.convert("RGBA")
+            rgba.thumbnail((width, 2048), Image.Resampling.LANCZOS)
+            white = Image.new("RGBA", rgba.size, "white")
+            gray = Image.alpha_composite(white, rgba).convert("L")
+            # Epson raster bits use 1 for ink; Pillow uses 1 for white.
+            ink = ImageOps.invert(gray.point(lambda value: 255 if value >= 128 else 0))
+            row_bytes = (ink.width + 7) // 8
+            padded = Image.new("1", (row_bytes * 8, ink.height), 0)
+            padded.paste(ink.convert("1"), (0, 0))
+            return (
+                b"\x1dv0\x00"
+                + struct.pack("
Date: Fri, 16 Oct 2026 18:51:02 +0200
Subject: [PATCH 3/5] Revert "Print configurable Epson receipt header images"

This reverts commit ad5f65ecc0dfc711c5c3a8b6730edc225db53449.
---
 README.md                 | 23 ---------------
 config.py                 |  4 ---
 config.yaml.example       |  5 ----
 plugins/POS.py            | 42 +++-------------------------
 tests/plugins/test_POS.py | 59 ---------------------------------------
 5 files changed, 4 insertions(+), 129 deletions(-)

diff --git a/README.md b/README.md
index 9b4a9d0..fcb17aa 100644
--- a/README.md
+++ b/README.md
@@ -9,29 +9,6 @@ You need python, paho-mqtt-client and apache+php.
 
 To install the python dependencies, run 'pip install -r requirements.txt'.
 
-### Receipt header image
-
-The Epson receipt header defaults to `images/hack42.png`. Configure another
-PNG/JPEG file and maximum width in printer dots in `config.yaml`:
-
-```yaml
-pos:
-  header:
-    image: images/hack42.png
-    width: 384
-```
-
-Relative image paths resolve from the project directory. Transparent pixels
-print white; the image is scaled down proportionally and converted to monochrome.
-The header is sent with each new receipt, stock overview, declaration, and party
-settlement. Existing saved receipts retain their original header when reprinted.
-Set `image: ""` to keep using the logo stored in the printer. If the configured
-file cannot be loaded, a warning is logged and the stored logo is used.
-
-This uses Epson's [GS v 0 raster command](https://download4.epson.biz/sec_pubs/pos/reference_en/escpos/gs_lv_0.html),
-which must be supported by the printer model. It does not change the printer's
-nonvolatile logo memory. Set `width` to at most your printer's printable width.
-
 ### Printer and cash drawer
 
 The POS serial port defaults to `/dev/ttyKASSA`. Install the udev rule on the
diff --git a/config.py b/config.py
index 92210b2..d1ddf43 100644
--- a/config.py
+++ b/config.py
@@ -28,10 +28,6 @@
         },
     },
     "pos": {
-        "header": {
-            "image": "images/hack42.png",
-            "width": 384,
-        },
         "serial": {
             "port": "/dev/ttyKASSA",
             "baudrate": 19200,
diff --git a/config.yaml.example b/config.yaml.example
index 1c48ed0..3601400 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -22,11 +22,6 @@ stickers:
     # identifier: tcp://BRW008092D4A414.space.hack42.nl:9100
 
 pos:
-  header:
-    # Relative to the project directory, or an absolute path.
-    # Set image to "" to use the logo already stored in the printer.
-    image: images/hack42.png
-    width: 384
   serial:
     port: /dev/ttyKASSA
     baudrate: 19200
diff --git a/plugins/POS.py b/plugins/POS.py
index e503385..c9920a4 100644
--- a/plugins/POS.py
+++ b/plugins/POS.py
@@ -4,12 +4,9 @@
 import json
 import logging
 import pickle
-from pathlib import Path
-import struct
 import time
 
 import serial
-from PIL import Image, ImageOps
 
 from config import config_get
 
@@ -70,39 +67,8 @@ def help(self):
             "printstock": "Print stock overview",
         }
 
-    def header_image(self):
-        settings = config_get("pos", "header", default={})
-        filename = settings.get("image", "")
-        if not filename:
-            return LOGO
-        try:
-            width = int(settings.get("width", 384))
-            if not 8 <= width <= 2048:
-                raise ValueError("Header width must be between 8 and 2048 dots")
-            path = Path(filename)
-            if not path.is_absolute():
-                path = Path(__file__).resolve().parent.parent / path
-            with Image.open(path) as source:
-                rgba = source.convert("RGBA")
-            rgba.thumbnail((width, 2048), Image.Resampling.LANCZOS)
-            white = Image.new("RGBA", rgba.size, "white")
-            gray = Image.alpha_composite(white, rgba).convert("L")
-            # Epson raster bits use 1 for ink; Pillow uses 1 for white.
-            ink = ImageOps.invert(gray.point(lambda value: 255 if value >= 128 else 0))
-            row_bytes = (ink.width + 7) // 8
-            padded = Image.new("1", (row_bytes * 8, ink.height), 0)
-            padded.paste(ink.convert("1"), (0, 0))
-            return (
-                b"\x1dv0\x00"
-                + struct.pack("
Date: Fri, 16 Oct 2026 18:52:53 +0200
Subject: [PATCH 4/5] Upload Epson logo once to nonvolatile printer memory

---
 Makefile                        |   4 +-
 README.md                       |  29 ++++++++
 load_printer_logo.py            | 105 +++++++++++++++++++++++++++++
 tests/test_load_printer_logo.py | 116 ++++++++++++++++++++++++++++++++
 4 files changed, 252 insertions(+), 2 deletions(-)
 create mode 100644 load_printer_logo.py
 create mode 100644 tests/test_load_printer_logo.py

diff --git a/Makefile b/Makefile
index 92bf406..b4301cd 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 PYTHON ?= python3
 NPM ?= npm
 PHP ?= php
-PY_FILES := kassa.py $(shell find plugins tests -type f -name '*.py' | sort)
+PY_FILES := kassa.py load_printer_logo.py $(shell find plugins tests -type f -name '*.py' | sort)
 PHP_FILES := $(shell find www -type f -name '*.php' | sort)
 
 .PHONY: test python-test js-test php-test venv pip-compile pip-sync pip-sync-dev lint python-lint js-lint php-lint check-types fix check
@@ -42,7 +42,7 @@ pip-sync-dev: ## synchronizes the .venv with the state of requirements.txt
 lint: python-lint js-lint php-lint  ## Do basic linting
 
 python-lint: venv
-	@. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py plugins
+	@. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py load_printer_logo.py plugins
 	@. .venv/bin/activate; for file in ${PY_FILES}; do ${env} ${PYTHON} -m black --check --quiet --workers 1 "$$file" || exit $$?; done
 
 node_modules/.package-lock.json: package.json package-lock.json
diff --git a/README.md b/README.md
index fcb17aa..8636feb 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,35 @@ remain supported. The adapter has no unique serial number, so this rule assumes
 only one `067b:2303` adapter is connected. With several identical adapters, narrow
 the rule to the appropriate USB port before installing it.
 
+### Store the receipt logo in the Epson
+
+Receipts use NV logo 1 already stored in the printer (`FS p 1 3`, the existing
+`LOGO` constant). Upload a replacement once with:
+
+```sh
+# Stop the kassa backend before uploading.
+.venv/bin/python load_printer_logo.py images/hack42.png --write --test-print
+```
+
+The script reads `pos.serial` from `config.yaml` (default `/dev/ttyKASSA`,
+19200 baud). Use `--config`, `--port`, or `--baudrate` to override it. Without
+`--write` it only prepares the image; `--output logo.bin` exports the `FS q`
+command for offline inspection. `--test-print` prints the stored logo after
+uploading. Restart the backend afterwards.
+
+The default stored width is 192 dots; `LOGO` prints at twice the stored width
+and height, so the printed width is at most 384 dots. `--width` changes the
+stored width. Images retain their aspect ratio; transparent pixels and padding
+become white. Stored logos persist after power-off; receipt generation does not
+upload the image again.
+
+Epson's [FS q command](https://download4.epson.biz/sec_pubs/pos/reference_en/escpos/fs_lq.html)
+replaces **all** existing NV logos/graphics and resets the printer. Run the
+script only when changing the logo, with the backend stopped, and keep the
+printer powered on until it finishes. A successful serial write cannot confirm
+that the printer accepted the logo; verify the test print. The printer must
+support the same legacy NV commands already used for the receipt header.
+
 ### data
 
 Data is stored in 'data'. In production it is also committed to git, but for
diff --git a/load_printer_logo.py b/load_printer_logo.py
new file mode 100644
index 0000000..f3dc6f7
--- /dev/null
+++ b/load_printer_logo.py
@@ -0,0 +1,105 @@
+"""Upload NV logo 1 for the existing Epson FS p receipt header."""
+
+import argparse
+from pathlib import Path
+import struct
+import time
+
+from PIL import Image, ImageOps
+import serial
+
+from config import load_config
+from plugins.POS import CENTER, CUT, FEED, LOGO, PRINTER
+
+
+def encode_logo(path, width=192):
+    """Encode FS q column data; LOGO prints this at twice the stored size."""
+    if not 8 <= width <= 512:
+        raise ValueError("Stored width must be between 8 and 512 dots")
+    with Image.open(path) as source:
+        image = source.convert("RGBA")
+    image.thumbnail((width, 512), Image.Resampling.LANCZOS)
+    white = Image.new("RGBA", image.size, "white")
+    gray = Image.alpha_composite(white, image).convert("L")
+    ink = ImageOps.invert(gray.point(lambda value: 255 if value >= 128 else 0))
+    x_bytes, y_bytes = (image.width + 7) // 8, (image.height + 7) // 8
+    padded = Image.new("1", (x_bytes * 8, y_bytes * 8), 0)
+    padded.paste(ink.convert("1"), (0, 0))
+    # FS q packs each column from top to bottom, most significant bit first.
+    columns = padded.transpose(Image.Transpose.TRANSPOSE).tobytes()
+    return b"\x1cq\x01" + struct.pack("
Date: Fri, 16 Oct 2026 18:53:09 +0200
Subject: [PATCH 5/5] Format upload script CLI option

---
 load_printer_logo.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/load_printer_logo.py b/load_printer_logo.py
index f3dc6f7..7c4154d 100644
--- a/load_printer_logo.py
+++ b/load_printer_logo.py
@@ -70,9 +70,7 @@ def main(argv=None):
     parser.add_argument("--config", type=Path, help="config.yaml override")
     parser.add_argument("--port", help="override pos.serial.port")
     parser.add_argument("--baudrate", type=int, help="override pos.serial.baudrate")
-    parser.add_argument(
-        "--output", type=Path, help="export FS q bytes to a file"
-    )
+    parser.add_argument("--output", type=Path, help="export FS q bytes to a file")
     parser.add_argument("--write", action="store_true", help="replace printer NV logos")
     parser.add_argument(
         "--test-print", action="store_true", help="print logo after upload"