Extface driver writing guide (Ruby)


I would like to show in practice how to write a new driver for https://github.com/AlexVangelov/extface module.

My example device will be Datecs Fiscal Printer FP550, which requires fast two-way communication.

First, let’s take a glance at the protocol.
We have packet messages from host to printer with sequence number and control sum:

and packet or non-packet messages from printer to host:

0x15 (NAK) – means that we have to re-transmit last packet with same sequence number (of course not infinity)
0x16 (ACK) – device has job to do and the host must wait (but nothing is forever again)

For sending packets we need a function with 2 input parameters (cmd, data). Length, sequence number and check sum will be generated automatically.
For receiving data, we can decide that if the stream contains 0x15, one or more of 0x16, or 0x03 – it may be a valid packet and must be processed.

Fork the project and create a new device driver skeleton:

git clone git://github.com/AlexVangelov/extface.git
cd ./extface
bundle install
bundle exec bin/rails generate extface:driver datecs/fp550

The last command will create:
app/models/extface/driver/datecs/fp550.rb
app/views/extface/driver/datecs/fp550/_settings.html.erb
app/views/extface/driver/datecs/fp550/_control.html.erb
test/models/extface/driver/datecs/fp550_test.rb

Layer 1 (Send & Receive)

Extface base driver functionality eliminates the need of thinking how data is transferred through the network. Sending data is easy, just call push(some_data) and it will be delivered to the device. For receiving data we use data = pull(timeout_in_seconds), but before that we should tell the driver what to expect from the input stream. There is a build in FIFO (First in – first out) buffer, that contains everything received from the device, and it is served to the driver through #handle(buffer) callback-like method. The method should return number of bytes processed, which will be auto-deleted from the beginning of buffer. If the received data is not enough to recognize a packet, the method may return nil and inspect the buffer next time, when a fresh data will be appended to it. We have to override that method:

def handle(buffer)
  if i = buffer.index(/[\x03\x16\x15]/)   # find position of frame possible delimiter
    rpush buffer[0..i]                    # this will make data available for #pull(timeout) method
    return i+1                            # return number of bytes processed
  end
end

For an unpretentious driver like simple terminal communication with line delimiter, nothing more needed. Just replace regex with index(‘\r\n\’) and you will be able to talk with device like:

device.session(‘Raw session’) do |s|
s.push “Extface rocks!”
data = s.pull(5) #wait for response 5 seconds
s.push “Extface really rocks!” if data.present?
end

Back to fiscal driver, check the frame recognition by writing #handle method test ( ‘test/models/extface/driver/datecs/fp550_test.rb’ ). Serial communication is unstable and the driver must be ready to process any random bytes without rising exception yet.

require 'test_helper'
module Extface
  class Driver::Datecs::Fp550Test < ActiveSupport::TestCase
    setup do
      @driver = extface_drivers(:datecs_fp550) # require device and driver fixtures
      @driver.flush # clear receive buffer
    end
    
    test "handle" do
      assert_equal nil, @driver.handle('bad packet')
      assert_equal 6, @driver.handle("\x01data\x03data"), "Frame not match"
      assert_equal 9, @driver.handle("pre\x01data\x03data"), "Frame with preamble not match"
      assert_equal 1, @driver.handle("\x16\x16\x01data\x03data"), "Frame with ACK not match"
      assert_equal 4, @driver.handle("pre\x15"), "NAK not match"
    end
  end
end

It’s time to declare all the command constants described in device specification. Creating a separate file keeps code clear and allows reuse it for the future Datecs drivers.

‘app/models/extface/driver/datecs/commands_v1.rb’

module Extface
  module Driver::Datecs::CommandsV1
    STX = 0x01
    PA1 = 0x05
    PA2 = 0x04
    ETX = 0x03
    NAK = 0x15
    SYN = 0x16
    module Init
      SET_MEMORY_SWITCHES         = 0x29
      SET_FOOTER                  = 0x2B
      ...
    end
    module Info
      GET_DATE_HOUR               = 0x3E
      ...
      GET_STATUS                  = 0x4A #Receiving the status bytes
    end
  end
end

Add ‘include Extface::Driver::Datecs::CommandsV1‘ to the driver model.
We need a method for building packets with 2 input parameters – 1 byte command and data binary string (not required). Auto generated sequence number, and check sum calculation procedure does not need to be public.

def build_packet(cmd, data = "")
  "".b.tap() do |packet|
    packet << STX                    #Preamble. 1 byte long. Value: 01H.
    packet << 0x20 + 4 + data.length #Number of bytes from  preamble (excluded) to  (included) plus the fixed offset of 20H
    packet << sequence_number        #Sequence number of the frame. Length : 1 byte. Value: 20H – FFH.
    packet << cmd                    #Length: 1 byte. Value: 20H - 7FH.
    packet << data                   #Length: 0 - 218 bytes for Host to printer
    packet << PA1                    #Post-amble. Length: 1 byte. Value: 05H.
    packet << bcc(packet[1..-1])     #Control sum (0000H-FFFFH). Length: 4 bytes. Value of each byte: 30H-3FH
    packet << ETX                    #Terminator. Length: 1 byte. Value: 03H.   end end private   def bcc(buffer)     sum = 0     buffer.each_byte{ |b| sum += b }     "".b.tap() do |bcc|       4.times do |halfbyte|         bcc.insert 0, (0x30 + ((sum >> (halfbyte*4)) & 0x0f)).chr
      end
    end
  end
 
  def sequence_number
    @seq ||= 0x1f
    @seq += 1
    @seq = 0x1f if @seq == 0x7f
    @seq
  end

To test the packet generation, find a valid packet example in documentation or obtain it by monitoring the vendor driver communication.

test "build packet" do
  assert_equal "\x01\x24\x20\x4a\x05\x30\x30\x39\x33\x03".b, @driver.build_packet(0x4a), "packet without data"
  assert_equal "\x01\x25\x21\x4a\x58\x05\x30\x30\x3e\x3d\x03".b, @driver.build_packet(0x4a, 'X'), "packet with data"
end

To keep the pleasure of programming, we can try to send (unconditionally) a simple command like paper cut to a real device. Prepare a rails application with extface module included in Gemfile with relative path (gem ‘extface’, path: ‘../extface’), a model with ‘has_extface_devices’ , and route ‘extface_for :model’  in resources section (see https://github.com/AlexVangelov/extface readme).  Go to model_extface_path and create a new device. The new driver ‘Datecs FP550’ is now available for selection (group Fiscal Printers & Cash Registers). Copy ‘Client Pull Url’ from device page and run extface client.

extface.exe http://localhost:3000/shops/1/shop_extface/a649a221ec1cebd0cacbc3ccf4846dba COM1,9600,8N1

There is only windows software client realized and if your development machine is unix based (like mine), you have to run it in a virtual windows machine or on a separate computer. In this case replace ‘localhost’ with IP address, and make sure your firewall settings will not block the port.

Our simple command will be push(build_packet(Printer::PAPER_CUT)). To make it available from the interface, we have to create method ‘paper_cut‘ in the driver model, and add a link in _control.html.erb (it’s the driver control panel and we will come back to it when the driver is ready)

app/models/extface/driver/datecs/fp550.rb

def paper_cut
  device.session('Paper Cut') do |s|
    s.push build_packet(Printer::PAPER_CUT)
  end
end

app/views/extface/driver/datecs/fp550/_control.html.erb

<%= button_to 'Paper Cut', fiscal_device_path(@device), remote: true, name: :paper_cut, value: true %>

The paper cut button will be accessible in control section of device page. In ideal conditions it should work, but it’s just a test and is not enough.
In the next layer of the driver we need to create a complex method that will build packet, send it to the device, re-transmit it if necessary, read the response packet, check status bytes (returned with every command) for errors, and return unpacked data if everything is OK.
But before that the driver should be able to receive packets from device.

A good approach to deal with a response packet, is to convert it to an object with clean properties (data length, sequence number, command, data bytes, status, check sum and error messages). It can be a private subclass of the driver model, with ActiveModel::Validations included. Any errors of the packet will be accessible through build in Errors object after initialization, nice!
Now I’m a little confused because it will repeat a similar functionality (check sum calculation). May be building packet should use the same subclass object… Anyway, for now we will make check sum calculation as a class method and reuse it. At the end we can play with optimizations and test memory and processor consumption for different variants.

class Frame
  include ActiveModel::Validations
  attr_reader :frame, :len, :seq, :cmd, :data, :status, :bcc
  
  validates_presence_of :frame, unless: :unpacked?
  validate :bcc_validation
  validate :len_validation
  
  def initialize(buffer)
    if match = buffer.match(/\x01(.{1})(.{1})(.{1})(.*)\x04(.{6})\x05(.{4})\x03/nm)
      @frame = match.to_a.first
      @len, @seq, @cmd, @data, @status, @bcc = match.captures
    else
      if buffer[/^\x16+$/] # only ACKs
        @ack = true
      elsif buffer.index("\x15")
        @nak = true
      end
    end
  end
  
  def ack?; !!@ack; end #should wait, response is yet to come
        
  def nak?; !!@nak; end #should retry command with same seq

  private
    def unpacked? # is it packed or unpacked message?
      @ack || @nak
    end

    def bcc_validation
      if unpacked?
        calc_bcc = self.class.bcc frame[1..-6]
        errors.add(:bcc, I18n.t('errors.messages.invalid')) if bcc != calc_bcc
      end
    end
    
    def len_validation
      unless unpacked?
        errors.add(:len, I18n.t('errors.messages.invalid')) if frame.nil? || len.ord != (frame[1..-6].length + 0x20)
      end
    end
  
    class << self       def bcc(buffer) #TODO remove old implementation         sum = 0         buffer.each_byte{ |b| sum += b }         "".tap() do |bcc|           4.times do |halfbyte|             bcc.insert 0, (0x30 + ((sum >> (halfbyte*4)) & 0x0f)).chr
          end
        end
      end
    end
end

I’m not gonna talk about regular expressions in ruby. You can extract the packet parts with any code you like. My personal practice is to play some time with an online regexp tester an then put it in the code. Then the tests will show whether it is correct. Find an example response packet and test the new class:

test "response frame" do
  frame_class = @driver.class::Frame
  assert frame_class.new("\x15").nak?, "NAK message failed"
  assert frame_class.new("\x16\x16").ack?, "ACK message failed"
  assert_nothing_raised do
    assert_equal false, frame_class.new("bad data\x01\x25\x21\x4asome broken packet\x58\x05\x30\x30\x3e\x3d\x03".b).valid?
  end
  frame = frame_class.new("\x16\x01\x2C\x2F\x2D\x50\x04\x88\x80\xC0\x80\x80\xB0\x05\x30\x34\x35\x39\x03".b)
  assert frame.valid?, "Vailid frame not recognized"
  assert_equal "\x01\x2C\x2F\x2D\x50\x04\x88\x80\xC0\x80\x80\xB0\x05\x30\x34\x35\x39\x03".b, frame.frame
  assert_equal "\x2c".b, frame.len
  assert_equal "\x2f".b, frame.seq
  assert_equal "\x2d".b, frame.cmd
  assert_equal "\x50".b, frame.data
  assert_equal "\x88\x80\xC0\x80\x80\xB0".b, frame.status
  assert_equal "\x30\x34\x35\x39".b, frame.bcc
  #bad check sum
  frame = frame_class.new("\x01\x2C\x2F\x2D\x50\x04\x88\x80\xC0\x80\x80\xB0\x05\x30\x34\x35\x38\x03".b)
  assert_equal false, frame.valid?
  assert frame.errors.messages[:bcc]
  #bad length
  frame = frame_class.new("\x01\x2b\x2F\x2D\x50\x04\x88\x80\xC0\x80\x80\xB0\x05\x30\x34\x35\x38\x03".b)
  assert_equal false, frame.valid?
  assert frame.errors.messages[:len]
end

The last thing we need before we move to the next layer is the ability to decode status bytes (included in each response packet). Messages must be human readable. Read device documentation carefully and check the bits that must stop session execution.

def human_status_errors(status) #inspect 6 bytes status
  status_0 = status[0].ord
  errors.add :base, "Fiscal Device General Error" unless (status_0 & 0x20).zero?
  ...
end

The topic is very spacious for a single article, so that’s it for now (will be continue)
10x for reading!

20 May 2015: Extface driver writing guide 2