# /usr/bin/env ruby require 'sinatra' require "sinatra/reloader" require 'tmpdir' require 'net/http' require_relative 'lib/receipt_builder' set :protection, :except => :frame_options set :environment, :production set :bind, "::" set :port, 80 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 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 # 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 '/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/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 @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