# /usr/bin/env ruby require 'sinatra' require "sinatra/reloader" require 'tmpdir' require 'net/http' require_relative 'lib/receipt_builder' require_relative 'lib/label_repository' set :protection, :except => :frame_options set :environment, :production # nginx reverse-proxies :80 -> here. Loopback-only and an unprivileged port # means no more cap_net_bind_service dance on every RVM ruby upgrade. set :bind, "127.0.0.1" set :port, 4567 if !File.exist?('config.json') || !File.readable?('config.json') raise RuntimeException, 'Could not read config.json' end # Direct libusb bulk transfer (bypassing the kernel usblp driver, matching how # brother_ql_web addresses its printers) times out on this Pi 3B's USB # controller -- back to the kernel driver via a stable udev symlink instead. PRINTER_DEVICE = '/dev/usb/lp_datamax' # see /etc/udev/rules.d/99-datamax.rules 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 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 LABEL_REPO = LabelRepository.new(File.join(File.dirname(__FILE__), 'data', 'labels')) BROTHER_PRINTERS = { 'ql560' => { port: 8560, label: 'Brother QL-560' }, 'ql700' => { port: 8700, label: 'Brother QL-700' }, 'ql1100' => { port: 9100, label: 'Brother QL-1100' } }.freeze configure do enable :static set :session_secret, File.read(File.join(File.dirname(__FILE__), '.session_secret')).strip enable :sessions set :views, File.join(File.dirname(__FILE__), 'views') set :public_folder, File.join(File.dirname(__FILE__), 'public') set :files, File.join(settings.public_folder, 'files') set :unallowed_paths, ['.', '..'] end configure :production do enable :reloader end helpers do def flash(message = '', level) case level when "success" session[:alert_success] = message when "danger" session[:alert_danger] = message when "warning" session[:alert_warning] = message end end # Renders a PDF to ZPL ^GFA graphic labels at the printer's native DPI, # bypassing CUPS/Gutenprint's raster pipeline which blurs text and 1px-shifts # output compared to native ZPL rendering. def pdf_to_zpl(pdf_path, dpi: PRINTER_DPI) Dir.mktmpdir do |dir| pattern = File.join(dir, 'page-%03d.pbm') ok = system('gs', '-q', '-dNOPAUSE', '-dBATCH', '-dSAFER', '-sDEVICE=pbmraw', "-r#{dpi}", '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', "-sOutputFile=#{pattern}", pdf_path) raise 'ghostscript rendering failed' unless ok Dir.glob(File.join(dir, 'page-*.pbm')).sort.map { |f| pbm_to_zpl_label(f) }.join end end def pbm_to_zpl_label(pbm_path) bytes = File.binread(pbm_path) header = /\AP4\s+(?:#[^\n]*\n\s*)*(\d+)\s+(\d+)\s/.match(bytes) raise "unreadable PBM header in #{pbm_path}" unless header width, height = header[1].to_i, header[2].to_i bytes_per_row = (width + 7) / 8 total_bytes = bytes_per_row * height body = bytes.byteslice(header.end(0), total_bytes) hexdata = body.unpack1('H*').upcase "^XA\n^MD#{PRINTER_DARKNESS}\n^PW#{width}\n^LL#{height}\n^FO0,0^GFA,#{total_bytes},#{total_bytes},#{bytes_per_row},#{hexdata}\n^XZ\n" end def print_to_datamax(data) File.open(PRINTER_DEVICE, 'wb') { |f| f.write(data) } 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 # Procedural 64x64 checkerboard (8px squares) -- no image library needed, # just to prove GS v 0 raster printing works end to end. def checkerboard_bitmap(size_px = 64, block_px = 8) width_bytes = size_px / 8 bytes = "".b size_px.times do |y| row_block = y / block_px width_bytes.times do |xb| byte = 0 8.times do |bit| x = xb * 8 + bit col_block = x / block_px byte |= (1 << (7 - bit)) if (row_block + col_block).even? end bytes << byte.chr end end bytes end # Demonstrates most of what the printer can do: text sizes/styles, EAN-13, # QR code, and a raster image -- mirrors /test-print's role for the Datamax. def print_epson_test_page receipt = ReceiptBuilder.new receipt.align(:center).bold.size(:big).text("TM-T20II TEST\n").size(:normal).bold(false) receipt.align(:left).text(('-' * 48) + "\n") receipt.section_separator('TEXT STYLES') receipt.text("Normal text\n") receipt.bold.text("Bold text\n").bold(false) receipt.underline.text("Underlined text\n").underline(false) receipt.size(:wide).text("Double width\n").size(:normal) receipt.size(:high).text("Double height\n").size(:normal) receipt.size(:big).text("Double both\n").size(:normal) receipt.section_separator('BARCODE') receipt.align(:center).ean13('2012345678902') receipt.section_separator('QR CODE') receipt.align(:center).qr('https://git.priesol.net/randy/home-gitops') receipt.section_separator('IMAGE') receipt.align(:center).image(64, 64, checkerboard_bitmap) receipt.align(:left).text(('-' * 48) + "\n") receipt.text("Printed: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}\n") receipt.feed(3).cut receipt.print!(PRINTER_DEVICE_EPSON) end ESCPOS_MNEMONICS = { 'ESC' => "\x1B", 'GS' => "\x1D", 'FS' => "\x1C", 'DLE' => "\x10", 'LF' => "\x0A", 'CR' => "\x0D", 'HT' => "\x09", 'NUL' => "\x00" }.freeze # Parses a line-based mnemonic ESC/POS notation, e.g.: # ESC a 0x01 # Align: Center # Hello # literal text # LF # GS k 0x03 12345678 0x00 # A trailing comment is only a comment when "#" is its own # whitespace-separated token -- not just stripped from the first "#" # anywhere in the line, which would mangle literal text like "#5 Fix # thing" (an issue reference, not a comment). Each remaining token # becomes a known control mnemonic, a raw `0xNN` byte, or literal text # (CP852-encoded) -- consecutive literal tokens are rejoined with a # single space so plain prose still reads correctly, while whitespace # around control/byte tokens is dropped since those are just for human # readability, not printer output. def parse_escpos_notation(text) bytes = "".b text.each_line do |line| prev_was_text = false tokens = line.split comment_at = tokens.index('#') tokens = tokens[0...comment_at] if comment_at tokens.each do |token| if ESCPOS_MNEMONICS.key?(token) bytes << ESCPOS_MNEMONICS[token] prev_was_text = false elsif token =~ /\A0x([0-9A-Fa-f]{2})\z/ bytes << [Regexp.last_match(1)].pack('H2') prev_was_text = false else bytes << ' ' if prev_was_text bytes << token.encode('CP852', invalid: :replace, undef: :replace, replace: '?') prev_was_text = true end end end bytes end # Prints already-formatted ESC/POS notation (see parse_escpos_notation) -- # for content prepared elsewhere or reprinted from the label repository, # unlike print_receipt's title/body template. def print_raw_escpos(content) receipt = ReceiptBuilder.new receipt.align(:left).raw(parse_escpos_notation(content)) receipt.feed(3).cut receipt.print!(PRINTER_DEVICE_EPSON) end # Renders ZPL to a PNG via Labelary's public API for an inline preview. # Fixed at the Datamax label size (4x6in @ 8dpmm ~= 203dpi) -- good enough # for a thumbnail, not meant to be exact for other sizes. def render_zpl_preview(zpl) uri = URI('https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/') res = Net::HTTP.post(uri, zpl, 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8') return nil unless res.is_a?(Net::HTTPSuccess) res.body end # Transparently reverse-proxies the current request to one of the # brother_ql_web instances, so all three printers (web UI and API alike) # are reachable through this single sinatra port instead of their own # dedicated ports. def proxy_to_brother(port, prefix, path) uri = URI("http://127.0.0.1:#{port}#{path}") uri.query = request.query_string unless request.query_string.empty? proxied = Net::HTTP.const_get(request.request_method.capitalize).new(uri) request.env.each do |key, value| next unless key.start_with?('HTTP_') && key != 'HTTP_HOST' header = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') proxied[header] = value end proxied['Content-Type'] = request.content_type if request.content_type proxied['X-Forwarded-Prefix'] = prefix proxied['X-Forwarded-Host'] = request.host proxied.body = request.body.read unless request.request_method == 'GET' res = Net::HTTP.start(uri.host, uri.port) { |http| http.request(proxied) } body = res.body || '' if res['Content-Type']&.include?('text/html') body = body.sub(/(]*>)/i) { "#{Regexp.last_match(1)}\n#{erb(:_navbar, layout: false)}" } end headers = {} res.each_header do |key, value| next if %w[transfer-encoding connection content-length].include?(key.downcase) headers[key] = value end [res.code.to_i, headers, [body]] end end before do @alert_success = session.delete(:alert_success) @alert_danger = session.delete(:alert_danger) @alert_warning = session.delete(:alert_warning) end get '/' do erb :index end get '/upload' do erb :upload end post '/shipping/zpl' do zpl = params[:zpl].to_s name = params[:save_as].to_s if zpl.strip.empty? flash 'ZPL text is empty', 'warning' redirect '/upload' end case params[:commit] when 'save', 'save_and_print' if name.empty? flash 'Name is required to save', 'warning' else LABEL_REPO.save(name: name, type: 'zpl', content: zpl) if params[:commit] == 'save_and_print' begin print_to_datamax(zpl) flash "Saved '#{name}' and sent to printer", 'success' rescue => e flash "Saved '#{name}', but print failed (#{e.message})", 'danger' end else flash "Saved '#{name}'", 'success' end end else # No explicit save/save_and_print action -- e.g. a plain API/CI call. # labelprint stays a "dumb" print API here: just print, nothing saved. begin print_to_datamax(zpl) flash 'ZPL sent to printer', 'success' rescue => e flash "ZPL print failed (#{e.message})", 'danger' end end redirect '/upload' end post '/upload' do if params[:file] tempfile = params[:file][:tempfile] filename = params[:file][:filename] filetype = File.extname filename filepath = File.join(settings.files, filename) File.open(filepath, 'wb') do |f| f.write tempfile.read end case filetype when ".pdf" begin zpl = pdf_to_zpl(filepath) print_to_datamax(zpl) flash "PDF: Upload successful (#{filename})", "success" rescue => e flash "PDF: Failed to print (#{e.message})", "danger" end when ".zpl" print_to_datamax(File.binread(filepath)) flash "ZPL: Upload successful (#{filename})", "success" else flash "#{filetype} is not supported. Only .pdf and .zpl!", "false" end File.delete(filepath) if File.exist?(filepath) else flash 'You have to choose a file', "warning" end redirect '/upload' end post '/test-print' do begin zpl = pdf_to_zpl(TEST_LABEL_PDF) print_to_datamax(zpl) flash 'Test label sent to printer', 'success' rescue => e flash "Test print failed (#{e.message})", 'danger' end redirect '/upload' end get '/epson' do erb :epson end post '/epson/test-print' do begin print_epson_test_page flash 'Test page sent to Epson printer', 'success' rescue => e flash "Epson test print failed (#{e.message})", 'danger' end redirect '/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 post '/epson/raw' do content = params[:content].to_s name = params[:save_as].to_s if content.strip.empty? flash 'Receipt text is empty', 'warning' redirect '/epson' end case params[:commit] when 'save', 'save_and_print' if name.empty? flash 'Name is required to save', 'warning' else LABEL_REPO.save(name: name, type: 'escpos', content: content) if params[:commit] == 'save_and_print' begin print_raw_escpos(content) flash "Saved '#{name}' and sent to printer", 'success' rescue => e flash "Saved '#{name}', but print failed (#{e.message})", 'danger' end else flash "Saved '#{name}'", 'success' end end else # No explicit save/save_and_print action -- e.g. a plain API/CI call. # labelprint stays a "dumb" print API here: just print, nothing saved. begin print_raw_escpos(content) flash 'Sent to Epson printer', 'success' rescue => e flash "Epson print failed (#{e.message})", 'danger' end end redirect '/epson' end get '/labels' do @labels = LABEL_REPO.all erb :labels end get '/labels/:id/edit' do @label = LABEL_REPO.find(params[:id]) halt 404, 'unknown label' unless @label erb :label_edit end post '/labels/:id' do label = LABEL_REPO.update(params[:id], name: params[:name].to_s, content: params[:content].to_s) halt 404, 'unknown label' unless label flash "Saved '#{label['name']}'", 'success' redirect '/labels' end get '/labels/:id/preview.png' do label = LABEL_REPO.find(params[:id]) halt 404 unless label && label['type'] == 'zpl' png = render_zpl_preview(label['content']) halt 502, 'preview unavailable' unless png content_type 'image/png' png end post '/labels/:id/print' do label = LABEL_REPO.find(params[:id]) halt 404, 'unknown label' unless label begin case label['type'] when 'zpl' print_to_datamax(label['content']) when 'escpos' print_raw_escpos(label['content']) end flash "Printed '#{label['name']}'", 'success' rescue => e flash "Print failed (#{e.message})", 'danger' end redirect '/labels' end post '/labels/:id/delete' do LABEL_REPO.delete(params[:id]) flash 'Label deleted', 'success' redirect '/labels' end get '/brother' do @printers = BROTHER_PRINTERS erb :brother_picker end %w[get post put delete].each do |verb| send(verb, '/brother/:printer/*') do printer = BROTHER_PRINTERS[params[:printer]] halt 404, 'unknown printer' unless printer proxy_to_brother(printer[:port], "/brother/#{params[:printer]}", '/' + params[:splat].first) end end