Add Epson TM-T20II receipt printing (title/body/QR, no ESC/POS knowledge needed)
deploy / deploy (push) Successful in 8s

Custom ReceiptBuilder instead of the escpos gem -- its diacritics handling
needed the same manual UTF-8->CP852 encode call anyway, based on earlier
experiments in ~/test/epson/.
This commit is contained in:
randy committed 2026-09-13 10:42:52 +02:00
1 parent 5c06f725f0
commit afae57aff4
4 files changed
+154

No files matched your search

+30
View File
@@ -3,6 +3,7 @@ require 'sinatra'
require "sinatra/reloader" require "sinatra/reloader"
require 'tmpdir' require 'tmpdir'
require 'net/http' require 'net/http'
require_relative 'lib/receipt_builder'
set :protection, :except => :frame_options set :protection, :except => :frame_options
set :environment, :production set :environment, :production
@@ -21,6 +22,8 @@ PRINTER_DPI = 203 # DatamaxONeil E4204B Mark III native head resolution
PRINTER_DARKNESS = 8 # lower than factory default to stop thermal dot-gain from fusing barcode bars PRINTER_DARKNESS = 8 # lower than factory default to stop thermal dot-gain from fusing barcode bars
TEST_LABEL_PDF = File.join(File.dirname(__FILE__), 'test-label.pdf') TEST_LABEL_PDF = File.join(File.dirname(__FILE__), 'test-label.pdf')
PRINTER_DEVICE_EPSON = '/dev/usb/lp_epson' # see /etc/udev/rules.d/99-epson.rules
BROTHER_PRINTERS = { BROTHER_PRINTERS = {
'ql560' => { port: 8560, label: 'Brother QL-560' }, 'ql560' => { port: 8560, label: 'Brother QL-560' },
'ql700' => { port: 8700, label: 'Brother QL-700' }, 'ql700' => { port: 8700, label: 'Brother QL-700' },
@@ -88,6 +91,19 @@ helpers do
File.open(PRINTER_DEVICE, 'wb') { |f| f.write(data) } File.open(PRINTER_DEVICE, 'wb') { |f| f.write(data) }
end end
# Builds a simple receipt (bold centered title + left-aligned body,
# optional QR code) so callers don't need to know ESC/POS escape codes.
def print_receipt(title:, body:, qr: nil)
receipt = ReceiptBuilder.new
receipt.align(:center).bold.text("#{title}\n").bold(false) unless title.to_s.empty?
receipt.align(:left).text("#{body}\n") unless body.to_s.empty?
unless qr.to_s.empty?
receipt.align(:center).feed(1).qr(qr).feed(1)
end
receipt.feed(3).cut
receipt.print!(PRINTER_DEVICE_EPSON)
end
# Transparently reverse-proxies the current request to one of the # Transparently reverse-proxies the current request to one of the
# brother_ql_web instances, so all three printers (web UI and API alike) # brother_ql_web instances, so all three printers (web UI and API alike)
# are reachable through this single sinatra port instead of their own # are reachable through this single sinatra port instead of their own
@@ -187,6 +203,20 @@ post '/test-print' do
redirect '/upload' redirect '/upload'
end end
get '/epson' do
erb :epson
end
post '/epson/print' do
begin
print_receipt(title: params[:title].to_s, body: params[:body].to_s, qr: params[:qr].to_s)
flash 'Sent to Epson printer', 'success'
rescue => e
flash "Epson print failed (#{e.message})", 'danger'
end
redirect '/epson'
end
get '/brother' do get '/brother' do
@printers = BROTHER_PRINTERS @printers = BROTHER_PRINTERS
erb :brother_picker erb :brother_picker
+106
View File
@@ -0,0 +1,106 @@
# Minimal chainable ESC/POS builder for the Epson TM-T20II.
#
# Written instead of pulling in the `escpos` gem: the gem's diacritics
# handling needed the same manual UTF-8->CP852 `.encode` call anyway, and a
# couple of its helpers didn't behave as documented against this printer --
# fewer moving parts doing it directly.
class ReceiptBuilder
INIT = "\x1B\x40".freeze
CP852 = "\x1B\x74\x12".freeze # code page for Slovak diacritics
ALIGN_LEFT = "\x1B\x61\x00".freeze
ALIGN_CENT = "\x1B\x61\x01".freeze
ALIGN_RIGH = "\x1B\x61\x02".freeze
BOLD_ON = "\x1B\x45\x01".freeze
BOLD_OFF = "\x1B\x45\x00".freeze
UNDER_ON = "\x1B\x2D\x01".freeze
UNDER_OFF = "\x1B\x2D\x00".freeze
TXT_NORM = "\x1D\x21\x00".freeze
TXT_BIG = "\x1D\x21\x11".freeze # double height and width
TXT_HIGH = "\x1D\x21\x01".freeze # double height
TXT_WIDE = "\x1D\x21\x10".freeze # double width
CUT = "\x1D\x56\x00".freeze
def initialize
@buffer = "".b
init
end
def init
@buffer << INIT << CP852
self
end
def text(str)
@buffer << str.encode('CP852', invalid: :replace, undef: :replace, replace: '?')
self
end
def align(mode)
@buffer << case mode
when :center then ALIGN_CENT
when :right then ALIGN_RIGH
else ALIGN_LEFT
end
self
end
def bold(enable = true)
@buffer << (enable ? BOLD_ON : BOLD_OFF)
self
end
def underline(enable = true)
@buffer << (enable ? UNDER_ON : UNDER_OFF)
self
end
def size(mode)
@buffer << case mode
when :big then TXT_BIG
when :high then TXT_HIGH
when :wide then TXT_WIDE
else TXT_NORM
end
self
end
# Native ESC/POS QR code
def qr(data, size: 6)
qr_data = data.b
store_len = qr_data.bytesize + 3
@buffer << "\x1D\x28\x6B\x03\x00\x31\x43" << size.chr
@buffer << "\x1D\x28\x6B\x03\x00\x31\x45\x30" # error correction L
@buffer << "\x1D\x28\x6B" << [store_len % 256, store_len / 256].pack('CC') << "\x31\x50\x30" << qr_data
@buffer << "\x1D\x28\x6B\x03\x00\x31\x51\x30" # print
self
end
# Native ESC/POS EAN-13
def ean13(code)
@buffer << "\x1D\x6B\x02" << code.b << "\x00"
self
end
def feed(lines = 3)
@buffer << ("\n" * lines)
self
end
def cut
@buffer << CUT
self
end
def render
@buffer
end
def print!(device)
File.open(device, 'wb') { |f| f.write(@buffer) }
end
end
+3
View File
@@ -12,6 +12,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/upload">Shipping labels</a> <a class="nav-link" href="/upload">Shipping labels</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/epson">Receipts</a>
</li>
</ul> </ul>
<span class="navbar-text me-2">Brother:</span> <span class="navbar-text me-2">Brother:</span>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
+15
View File
@@ -0,0 +1,15 @@
<form action="/epson/print" method="POST">
<div class="mb-3">
<label for="title" class="form-label">Title (bold, centered)</label>
<input name="title" class="form-control" type="text" id="title" />
</div>
<div class="mb-3">
<label for="body" class="form-label">Body text</label>
<textarea name="body" class="form-control" id="body" rows="8"></textarea>
</div>
<div class="mb-3">
<label for="qr" class="form-label">QR code content (optional)</label>
<input name="qr" class="form-control" type="text" id="qr" />
</div>
<button class="btn btn-secondary" type="submit">Print</button>
</form>