BallAerospace/COSMOS

View on GitHub
cosmos/lib/cosmos/interfaces/protocols/template_protocol.rb

Summary

Maintainability
C
1 day
Test Coverage
# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# This program may also be used under the terms of a commercial or
# enterprise edition license of COSMOS if purchased from the
# copyright holder

require 'cosmos/config/config_parser'
require 'cosmos/interfaces/protocols/terminated_protocol'
require 'thread' # For Queue
require 'timeout' # For Timeout::Error

module Cosmos
  # Protocol which delineates packets using delimiter characters. Designed for
  # text based protocols which expect a command and send a response. The
  # protocol handles sending the command and capturing the response.
  class TemplateProtocol < TerminatedProtocol
    # @param write_termination_characters (see TerminatedProtocol#initialize)
    # @param read_termination_characters (see TerminatedProtocol#initialize)
    # @param ignore_lines [Integer] Number of newline terminated reads to
    #   ignore when processing the response
    # @param initial_read_delay [Integer] Initial delay when connecting before
    #   trying to read
    # @param response_lines [Integer] Number of newline terminated lines which
    #   comprise the response
    # @param strip_read_termination (see TerminatedProtocol#initialize)
    # @param discard_leading_bytes (see TerminatedProtocol#initialize)
    # @param sync_pattern (see TerminatedProtocol#initialize)
    # @param fill_fields (see TerminatedProtocol#initialize)
    # @param response_timeout [Float] Number of seconds to wait before timing out
    #   when waiting for a response
    # @param response_polling_period [Float] Number of seconds to wait between polling
    #   for a response
    # @param raise_exceptions [String] Whether to raise exceptions when errors
    #   occur in the protocol like unexpected responses or response timeouts.
    # @param allow_empty_data [true/false/nil] See Protocol#initialize
    def initialize(
      write_termination_characters,
      read_termination_characters,
      ignore_lines = 0,
      initial_read_delay = nil,
      response_lines = 1,
      strip_read_termination = true,
      discard_leading_bytes = 0,
      sync_pattern = nil,
      fill_fields = false,
      response_timeout = 5.0,
      response_polling_period = 0.02,
      raise_exceptions = false,
      allow_empty_data = nil
    )
      super(
        write_termination_characters,
        read_termination_characters,
        strip_read_termination,
        discard_leading_bytes,
        sync_pattern,
        fill_fields,
        allow_empty_data)
      @response_template = nil
      @response_packet = nil
      @response_target_name = nil
      @response_packets = []
      @write_block_queue = Queue.new
      @ignore_lines = ignore_lines.to_i
      @response_lines = response_lines.to_i
      @initial_read_delay = ConfigParser.handle_nil(initial_read_delay)
      @initial_read_delay = @initial_read_delay.to_f if @initial_read_delay
      @response_timeout = ConfigParser.handle_nil(response_timeout)
      @response_timeout = @response_timeout.to_f if @response_timeout
      @response_polling_period = response_polling_period.to_f
      @connect_complete_time = nil
      @raise_exceptions = ConfigParser.handle_true_false(raise_exceptions)
    end

    def reset
      super()
      @initial_read_delay_needed = true
    end

    def connect_reset
      super()
      begin
        @write_block_queue.pop(true) while @write_block_queue.length > 0
      rescue
      end

      @connect_complete_time = Time.now + @initial_read_delay if @initial_read_delay
    end

    def disconnect_reset
      super()
      @write_block_queue << nil # Unblock the write block queue
    end

    def read_data(data)
      return super(data) if data.length <= 0

      # Drop all data until the initial_read_delay is complete.
      # This gets rid of unused welcome messages,
      # prompts, and other junk on initial connections
      if @initial_read_delay and @initial_read_delay_needed and @connect_complete_time
        return :STOP if Time.now < @connect_complete_time

        @initial_read_delay_needed = false
      end
      super(data)
    end

    def read_packet(packet)
      if @response_template && @response_packet
        # If lines make it this far they are part of a response
        @response_packets << packet
        return :STOP if @response_packets.length < (@ignore_lines + @response_lines)

        @ignore_lines.times do
          @response_packets.shift
        end
        response_string = ''
        @response_lines.times do
          response = @response_packets.shift
          response_string << response.buffer
        end

        # Grab the response packet specified in the command
        result_packet = System.telemetry.packet(@response_target_name, @response_packet).clone
        result_packet.received_time = nil
        result_packet.id_items.each do |item|
          result_packet.write_item(item, item.id_value, :RAW)
        end

        # Convert the response template into a Regexp
        response_item_names = []
        response_template = @response_template.clone
        response_template_items = @response_template.scan(/<.*?>/)

        response_template_items.each do |item|
          response_item_names << item[1..-2]
          response_template.gsub!(item, "(.*)")
        end
        response_regexp = Regexp.new(response_template)

        # Scan the response for the variables in brackets <VARIABLE>
        # Write the packet value with each of the values received
        response_values = response_string.scan(response_regexp)[0]
        if !response_values || (response_values.length != response_item_names.length)
          handle_error("#{@interface ? @interface.name : ""}: Unexpected response: #{response_string}")
        else
          response_values.each_with_index do |value, i|
            result_packet.write(response_item_names[i], value)
          rescue => error
            handle_error("#{@interface ? @interface.name : ""}: Could not write value #{value} due to #{error.message}")
            break
          end
        end

        @response_packets.clear

        # Release the write
        if @response_template && @response_packet
          @write_block_queue << nil
        end

        return result_packet
      else
        return packet
      end
    end

    def write_packet(packet)
      # Make sure we are past the initial data dropping period
      if @initial_read_delay and @initial_read_delay_needed and @connect_complete_time and Time.now < @connect_complete_time
        delay_needed = @connect_complete_time - Time.now
        sleep(delay_needed) if delay_needed > 0
      end

      # First grab the response template and response packet (if there is one)
      begin
        @response_template = packet.read("RSP_TEMPLATE").strip
        @response_packet = packet.read("RSP_PACKET").strip
        @response_target_name = packet.target_name
        # If the template or packet are empty set them to nil. This allows for
        # the user to remove the RSP_TEMPLATE and RSP_PACKET values and avoid
        # any response timeouts
        if @response_template.empty? || @response_packet.empty?
          @response_template = nil
          @response_packet = nil
          @response_target_name = nil
        end
      rescue
        # If there is no response template we set to nil
        @response_template = nil
        @response_packet = nil
        @response_target_name = nil
      end

      # Grab the command template because that is all we eventually send
      @template = packet.read("CMD_TEMPLATE")
      # Create a new packet to populate with the template
      raw_packet = Packet.new(nil, nil)
      raw_packet.buffer = @template
      raw_packet = super(raw_packet)
      return raw_packet if Symbol === raw_packet

      data = raw_packet.buffer(false)
      # Scan the template for variables in brackets <VARIABLE>
      # Read these values from the packet and substitute them in the template
      # and in the @response_packet name
      @template.scan(/<(.*?)>/).each do |variable|
        value = packet.read(variable[0], :RAW).to_s
        data.gsub!("<#{variable[0]}>", value)
        @response_packet.gsub!("<#{variable[0]}>", value) if @response_packet
      end

      return raw_packet
    end

    def post_write_interface(packet, data)
      if @response_template && @response_packet
        if @response_timeout
          response_timeout_time = Time.now + @response_timeout
        else
          response_timeout_time = nil
        end

        # Block the write until the response is received
        begin
          @write_block_queue.pop(true)
        rescue
          sleep(@response_polling_period)
          retry if !response_timeout_time
          retry if response_timeout_time and Time.now < response_timeout_time
          handle_error("#{@interface ? @interface.name : ""}: Timeout waiting for response")
        end

        @response_template = nil
        @response_packet = nil
        @response_target_name = nil
        @response_packets.clear
      end
      return super(packet, data)
    end

    def handle_error(msg)
      Logger.error(msg)
      raise msg if @raise_exceptions
    end
  end
end