Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions load_printer_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""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("<HH", x_bytes, y_bytes) + columns


def upload_logo(port, baudrate, payload, test_print=False):
"""Write once, then allow the printer to finish its NV write/reset."""
with serial.Serial(
port=port, baudrate=baudrate, timeout=5, write_timeout=10, exclusive=True
) as printer:
data = PRINTER + payload
for offset in range(0, len(data), 32):
chunk = data[offset : offset + 32]
if printer.write(chunk) != len(chunk):
raise OSError("Incomplete serial write; logo upload interrupted")
time.sleep(0.05)
printer.flush()
time.sleep(5)
if test_print:
proof = PRINTER + CENTER + LOGO + FEED + CUT
if printer.write(proof) != len(proof):
raise OSError("Incomplete serial write during test print")
printer.flush()


def main(argv=None):
parser = argparse.ArgumentParser(
description="Store Epson NV logo 1. Replaces ALL existing NV logos. "
"Stop the kassa backend first and keep the printer powered on."
)
parser.add_argument(
"image",
nargs="?",
type=Path,
default=Path(__file__).resolve().parent / "images/hack42.png",
)
parser.add_argument(
"--width",
type=int,
default=192,
help="stored width in dots; receipts print at 2x (default: 192)",
)
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("--write", action="store_true", help="replace printer NV logos")
parser.add_argument(
"--test-print", action="store_true", help="print logo after upload"
)
args = parser.parse_args(argv)
if args.test_print and not args.write:
parser.error("--test-print requires --write")
try:
payload = encode_logo(args.image, args.width)
if args.output:
args.output.write_bytes(payload)
if args.write:
settings = load_config(args.config)["pos"]["serial"]
upload_logo(
args.port or settings["port"],
args.baudrate or int(settings["baudrate"]),
payload,
args.test_print,
)
print("NV logo 1 sent to printer; verify with a test print.")
else:
print(
f"Prepared {len(payload)} bytes. Use --write to replace printer NV logos."
)
except (OSError, ValueError, serial.SerialException) as exc:
parser.exit(1, f"Logo upload failed: {exc}\n")


if __name__ == "__main__":
main()
116 changes: 116 additions & 0 deletions tests/test_load_printer_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from pathlib import Path
import runpy
import struct
from unittest.mock import Mock, patch

from PIL import Image
import pytest

import load_printer_logo as loader
from plugins.POS import CENTER, CUT, FEED, LOGO, PRINTER


def test_column_encoding_transparency_and_padding(tmp_path):
path = tmp_path / "logo.png"
image = Image.new("RGBA", (9, 9), (0, 0, 0, 0))
image.putpixel((0, 0), (0, 0, 0, 255))
image.putpixel((8, 8), (0, 0, 0, 255))
image.save(path)

payload = loader.encode_logo(path)

expected = bytearray(32)
expected[0] = 0x80
expected[17] = 0x80
assert payload == b"\x1cq\x01" + struct.pack("<HH", 2, 2) + expected


def test_logo_scales_proportionally(tmp_path):
path = tmp_path / "logo.png"
Image.new("RGB", (32, 16), "black").save(path)
assert loader.encode_logo(path, 16) == (
b"\x1cq\x01" + struct.pack("<HH", 2, 1) + b"\xff" * 16
)


@pytest.mark.parametrize("width", [0, 513])
def test_invalid_width_does_not_load_or_upload(width):
with pytest.raises(ValueError):
loader.encode_logo("missing.png", width)


@pytest.mark.parametrize("test_print", [False, True])
def test_upload_writes_complete_command_and_optional_existing_logo(test_print):
printer = Mock()
printer.write.side_effect = len
payload = b"\x1cq\x01" + bytes(80)
with patch.object(loader.serial, "Serial") as serial_mock, patch.object(
loader.time, "sleep"
):
serial_mock.return_value.__enter__.return_value = printer
loader.upload_logo("/dev/test", 19200, payload, test_print)

serial_mock.assert_called_once_with(
port="/dev/test", baudrate=19200, timeout=5, write_timeout=10, exclusive=True
)
written = b"".join(call.args[0] for call in printer.write.call_args_list)
proof = PRINTER + CENTER + LOGO + FEED + CUT if test_print else b""
assert written == PRINTER + payload + proof
assert printer.flush.call_count == (2 if test_print else 1)


@pytest.mark.parametrize("results", [[0], [len(PRINTER) + 1, 0]])
def test_upload_rejects_short_writes(results):
with patch.object(loader.serial, "Serial") as serial_mock, patch.object(
loader.time, "sleep"
):
serial_mock.return_value.__enter__.return_value.write.side_effect = results
with pytest.raises(OSError, match="Incomplete serial write"):
loader.upload_logo("/dev/test", 19200, b"x", test_print=True)


def test_cli_default_is_offline_and_exports_payload(tmp_path, capsys):
output = tmp_path / "logo.bin"
with patch.object(loader, "upload_logo") as upload:
loader.main(["--output", str(output)])
upload.assert_not_called()
assert output.read_bytes().startswith(b"\x1cq\x01")
assert "Use --write" in capsys.readouterr().out


def test_cli_uses_serial_config(tmp_path, capsys):
config = tmp_path / "config.yaml"
config.write_text(
"pos:\n serial:\n port: /dev/configured\n baudrate: 9600\n"
)
with patch.object(loader, "upload_logo") as upload:
loader.main(["--config", str(config), "--write", "--test-print"])
assert upload.call_args.args[:2] == ("/dev/configured", 9600)
assert upload.call_args.args[3] is True
assert "verify with a test print" in capsys.readouterr().out


def test_cli_serial_overrides():
with patch.object(loader, "upload_logo") as upload:
loader.main(["--write", "--port", "/dev/override", "--baudrate", "4800"])
assert upload.call_args.args[:2] == ("/dev/override", 4800)


def test_cli_print_requires_write():
with pytest.raises(SystemExit) as error:
loader.main(["--test-print"])
assert error.value.code == 2


def test_cli_serial_error_has_nonzero_exit(capsys):
with patch.object(loader, "upload_logo", side_effect=OSError("port busy")):
with pytest.raises(SystemExit) as error:
loader.main(["--write"])
assert error.value.code == 1
assert "port busy" in capsys.readouterr().err


def test_cli_entrypoint_is_offline(capsys):
with patch("sys.argv", ["load_printer_logo.py"]):
runpy.run_path(str(Path(loader.__file__)), run_name="__main__")
assert "Use --write" in capsys.readouterr().out
Loading