L3Harris PRC-163 radio adapter.
This device reads and write asynchronously from a USB TTY device connected to the radio. It has a thread managing the low-level IO, and a timer to periodically send the commands that turn into the events required by the BDE (GPS, time, etc.).
Both reading and writing happen in two stages: a queue and a buffer.
write_queue
-> write_bufffer -> <TTY> -> read_buffer
-> read_queue
Commands for the radio are appended to the write_queue
. The IO thread pulls each command off of the queue and appends it to the write_buffer
, then writes as much as it can to the radio. It then reads any pending output from the radio into the read_buffer
and then breaks as many whole lines off of that as it can, appending each one to the read_queue
. The read_queue
is then scanned for events, and those are emitted up the tree.
== Emits:
-
sys.device.info
-
sys.gps.position
-
sys.gps.time
-
sys.radio.battery
-
sys.radio.channel
-
sys.radio.volume
-
sys.radio.versions
== Consumes:
This class includes code from the ruby-termios examples, used under the terms of the Ruby License. The original software does not include a copyright statement, so none is duplicated here.
Refs:
- AUTOVIVIFY
Auto-vivifying Hash Proc; used to create Hashes that auto-expand their contents
- COMMAND_PROMPT
Prompt text that is used to split up output
- IGNORED_LINE
Lines that are ignored while parsing multi-line command output
- IO_SELECT_TIMEOUT
Maximum number of floating-point seconds to wait in the IO loop. This is effectively the maximum amount of time a command will spend in the write_queue
before being sent.
- L3H_DMS_FORMAT
Pattern for matching L3H’s weird degrees/minutes/seconds format.
- LINE_SEPARATOR
Character/s used to split radio output into lines
- MAX_READ_SIZE
Maximum of bytes to read from the radio at one time
- OOB_MESSAGE_LINE
Pattern to match ongoing (out-of-band) status message lines in radio output
- PRESET_CHANGE_COMPLETE
Pattern that matches the OOB message when a preset is changed
- RESET_THROTTLE_TIME
How long to pause when resetting to prevent the device from spinning
- io_thread R
The Thread object that is reading and writing to/from the radio serial device
- periodic_command_timer R
Concurrent::TimerTask that causes periodic information events to be emitted from the device.
- read_buffer R
A String buffer that contains output that has been read from the radio but not yet split into lines into the read_queue
- read_queue R
Line-oriented output from the radio that is waiting to be parsed
- write_buffer R
A String with unwritten commands for the radio. Commands are appended to this as they’re removed from the write_queue
by the IO thread.
- write_queue R
Commands for the radio that haven’t been written yet.
Create a new device adapter for a L3H PRC-163 radio.
def initialize( * )
@radio = nil
@io_thread = nil
@read_queue = []
@read_buffer = String.new
@write_queue = []
@write_buffer = String.new
@periodic_command_timer = self.make_periodic_command_timer or
raise "couldn't create the periodic read timer"
if self.class.simulation_mode?
self.log.warn "Adding simulation mode to %p" % [ self ]
self.extend( SimulationMode )
end
super
end
Append any data in the write_queue
to the write_buffer
.
def buffer_pending_writes
while (data = self.write_queue.shift)
self.write_buffer << data << LINE_SEPARATOR
end
end
build_gps_message( gps_info )
Construct and return a ‘sys.gps.position’ message using the given gps_info
.
def build_gps_message( gps_info )
return unless gps_info[ :state ] == :tracking
gps = {
pos: [ gps_info[ :latitude ], gps_info[ :longitude ] ],
hae: gps_info[ :altitude ],
ce: gps_info[ :epe ],
heading: gps_info[ :heading ],
velocity: gps_info[ :velocity ]
}
return Ravn::HAL::Message.new( 'sys.gps.position', data: gps )
end
build_gps_time_message( gps_info )
Construct and return a ‘sys.gps.time’ message using the given gps_info
.
def build_gps_time_message( gps_info )
time = gps_info[ :timestamp ].to_i
return Ravn::HAL::Message.new( 'sys.gps.time', data: { time: time } )
end
Connect to the radio via serial UART.
def build_radio_connection
device = self.class.serial_device
speed = self.class.serial_speed
mode = self.class.serial_mode
self.log.info "Opening radio device: %s at %d(%s)" % [ device, speed, mode ]
uart = UART.open( device.to_s, speed, mode )
desc = self.dump_termios( uart )
self.log.info "Radio device terminal capabilities: %s" % [ desc ]
return uart
rescue SystemCallError => err
self.log.error "%p while opening the serial device: %s" % [ err.class, err.message ]
raise
end
emit_preset_change_complete_event( match_data )
Emit a sys.radio.channel event given match_data
for a OOB SYS_PRESETSTATUS line.
def emit_preset_change_complete_event( match_data )
transceiver = match_data[:transceiver][ /(\d+)/, 1 ] or
raise "no transceiver in match data: %p" % [ match_data ]
data = {
radio: self.class.object_id,
transceiver: transceiver.to_i,
name: match_data[:preset_name],
number: match_data[:preset_number].to_i,
waveform: match_data[:waveform],
}
message = Ravn::HAL::Message.new( 'sys.radio.channel', data: )
self.filter_up( message )
end
Return a Hash that contains information describing this device for intra-device communication; overridden to set the correct vendor
.
def gather_device_info
return super.merge( vendor: 'l3harris' )
end
handle_oob_status_line( line )
Handle any status lines emitted by the radio by emitting events for the stuff we care about.
def handle_oob_status_line( line )
case line
when PRESET_CHANGE_COMPLETE
self.emit_preset_change_complete_event( $~ )
else
self.log.debug "Unhandled OOB status line: %p" % [ line ]
end
end
Stop reading/writing timers while paused
def handle_paused_event( * )
self.periodic_command_timer&.shutdown
@periodic_command_timer = self.make_periodic_command_timer
end
handle_resetting_event( * )
Tear down the radio connection and wait for the IO thread to stop when the device is going to be reset.
def handle_resetting_event( * )
self.log.warn "Resetting the radio device and waiting on the IO thread."
@radio&.close
@radio = nil
self.io_thread&.join( RESET_THROTTLE_TIME )
end
handle_resumed_event( * )
Recreate and restart the reading/writing timers when resumed
def handle_resumed_event( * )
self.periodic_command_timer.execute
end
Connect to the radio and loop over the IO routine for it while it’s open.
def io_loop
Thread.current.report_on_exception = true
self.log.info "Starting IO loop."
while self.radio && ! self.radio.closed?
self.read_and_write
end
self.log.info "Stopped IO loop."
rescue SystemCallError => err
self.log.error( err )
reason = "%p in the IO loop: %s" % [ err.class, err.message ]
self.reset( reason )
end
Turn data in the read_queue
into events if possible.
def parse_and_emit_events
self.log.debug "Parsing and emitting events from the read queue..."
chunks = self.read_queue.slice_after( COMMAND_PROMPT )
self.log.debug "Chunks: %p" % [ chunks.to_a ]
chunks.each do |chunk|
unless chunk.last.match?( COMMAND_PROMPT )
self.log.debug "No command prompt in the last line of this chunk: %p" % [ chunk.last ]
break
end
self.log.debug "Looking for events in chunk: %p" % [ chunk ]
self.parse_event_from_chunk( chunk ) or break
self.log.debug "Successfully parsed chunk; dropping %d lines from the queue" % [ chunk.size ]
self.read_queue.drop( chunk.size )
end
rescue => err
self.log.error "%p while parsing for events: %s" % [ err.class, err.full_message ]
raise
end
parse_event_from_chunk( lines )
Extract data from the given chunk of input lines
and emit an event for it if it’s interesting.
def parse_event_from_chunk( lines )
case lines.first
when /^VERSION /i
self.parse_into_versions_event( lines )
when /^BATTERY\b/i
self.parse_into_battery_event( lines )
when /^GPS\b/i
self.parse_into_gps_event( lines )
when /^RT[12] STATE\b/i
self.parse_into_channel_event( lines )
when /^RT[12] VOLUME\b/i
self.parse_into_volume_event( lines )
else
self.log.debug "Unhandled multi-line event: %s" % [ lines.join("⏎") ]
end
end
parse_into_battery_event( lines )
Parse the given BATTERY output lines
and emit an event describing the battery status.
def parse_into_battery_event( lines )
radio = self.class.object_id
data = lines.each_with_object({ radio: }) do |line, hash|
case line
when IGNORED_LINE
next
when /^BATTERY HUB BOD (.*)$/
timestamp = Time.strptime( $1.strip, '%a %b %e %H:%M:%S %Y' )
hash[:hub] ||= {}
hash[:hub][:bod] = timestamp
when /^BATTERY HUB CAPACITY DAYS REMAINING (\d+)/
hash[:hub] ||= {}
hash[:hub][:capacity] = $1.to_i
when /^BATTERY TYPE (\w+)/
hash[:type] = $1
when /^BATTERY MODEL (\w+)/
hash[:model] = $1
when /^BATTERY CAPACITY (\w+)/
hash[:capacity] = $1
when /^BATTERY STATUS (\w+)/
hash[:status] = $1
when /^BATTERY CHARGE (\d+)/
hash[:charge] = $1.to_i
when /^BATTERY VOLTAGE (\d+)/
hash[:voltage] = $1.to_i
when /^BATTERY CURRENT (\d+)/
hash[:current] = $1.to_i
when /^BATTERY TEMP (\d+)/
hash[:temp] = $1.to_i
else
self.log.debug "Unhandled battery info line: %p" % [ line ]
end
end
message = Ravn::HAL::Message.new( 'sys.radio.battery', data: )
self.filter_up( message )
end
parse_into_channel_event( lines )
Parse the given RT STATE output lines
and emit an event describing the transceiver’s channel state.
def parse_into_channel_event( lines )
radio = self.class.object_id
transceiver = lines.first[ /^RT([12])/i, 1 ] or
raise "failed to match transceiver number from %p" % [ lines.first ]
transceiver = transceiver.to_i
data = lines.each_with_object({ radio:, transceiver: }) do |line, hash|
case line
when IGNORED_LINE
next
when /^RT[12] STATE OPMODE (\S+)$/
hash[:opmode] = $1.downcase.to_sym
when /^RT[12] STATE SYS_PRESET_NAME (.+)$/
hash[:name] = $1.strip
when /^RT[12] STATE SYS_PRESET_NUMBER\s+(\d+)/
hash[:number] = $1.to_i
when /^RT[12] STATE CURRENT_WAVEFORM (.+)$/
hash[:waveform] = $1.strip
when /^RT[12] STATE OPERATIONAL_STATE (\w+)/
hash[:state] = $1.downcase.to_sym
when /^RT[12] STATE MISSION_PLAN (\S+)/
hash[:plan] = $1
when /^RT[12] STATE TYPE1-I (\S+)/
hash[:encrypted] = ($1 == 'TRUE')
else
self.log.debug "Unhandled transceiver state line: %p" % [ line ]
end
end
message = Ravn::HAL::Message.new( 'sys.radio.channel', data: )
self.filter_up( message )
end
parse_into_gps_event( lines )
Read GPS info from the given GPS output lines
emit an event describing the current GPS position.
def parse_into_gps_event( lines )
info = lines.each_with_object({}) do |line, hash|
case line
when IGNORED_LINE
when /^GPS INFO\s*$/
when /^GPS INFO POS1 (.*)/
hash[ :pos1 ] = $1
when /^GPS INFO POS2 (.*)/
hash[ :pos2 ] = $1
when /^GPS INFO STATE (\w+)/
hash[ :state ] = $1.downcase.to_sym
when /^GPS INFO LATITUDE\s+(.+)/
hash[ :latitude ] = parse_dms_position( $1 )
when /^GPS INFO LONGITUDE\s+(.+)/
hash[ :longitude ] = parse_dms_position( $1 )
when /^GPS INFO ALTITUDE\s+(.+)/
hash[ :altitude ] = $1.to_f
when /^GPS INFO SEPARATION\s+(.+)/
hash[ :separation ] = $1.to_f
when /^GPS INFO HEADING\s+(.+)/
hash[ :heading ] = $1.to_f
when /^GPS INFO VELOCITY\s+(.+)/
hash[ :velocity ] = $1.to_f
when /^GPS INFO FOM\s+(.+)/
hash[ :fom ] = $1.to_i
when /^GPS INFO TFOM\s+(.+)/
hash[ :tfom ] = $1.to_i
when /^GPS INFO EPE\s+\+\/-\s+(.+?) m/
hash[ :epe ] = $1.to_f
when /^GPS INFO DATUM\s+(.+)/
hash[ :datum ] = $1.downcase.to_sym
when /^GPS INFO KEYSTATUS\s+(.+)/
hash[ :keystatus ] = $1.downcase.to_sym
when /^GPS INFO VALIDITY\s+(.+)/
hash[ :validity ] = $1.downcase.to_sym
when /^GPS INFO TIMESTAMP\s+(.+)/
begin
munged_time = $1 + ' UTC'
time = Time.strptime( munged_time, '%Y-%m-%d %H:%M:%S %Z' )
hash[ :timestamp ] = time
rescue ArgumentError => err
self.log.warn "%p while parsing GPS timestamp: %s" % [ err.class, err.message ]
end
else
self.log.debug "Unknown GPS INFO line: %p" % [ line ]
end
end
if (message = self.build_gps_message( info ))
self.filter_up( message )
else
self.log.warn "couldn't create GPS position message (GPS state = %p)" % [ info[:state] ]
end
if (message = self.build_gps_time_message( info ))
self.filter_up( message )
else
self.log.warn "couldn't create GPS time message (GPS state = %p)" % [ info[:state] ]
end
end
parse_into_versions_event( lines )
Parse the given VERSION output lines
and emit an event describing various versions of hardware and software running on the radio.
def parse_into_versions_event( lines )
data = Hash.new( &AUTOVIVIFY )
data[ :radio ] = self.class.object_id
lines.each do |line|
case line
when IGNORED_LINE
next
when /^VERSION ALL/
next
when %r{^VERSION OPTION NAME (?<name>\w+)\s+P/N (?<partno>\S+)\s+(?<desc>.+)$}
hashify_captures_into( $~, data[:options] )
when /^VERSION HW (?<name>\w+)\s+(?<sku>\w+)\s+PN (?<partno>\S+)\s+PL_REV (?<plrev>\w+)\s+PWB_REV (?<pwbrev>\w+)/
hashify_captures_into( $~, data[:hw] )
when %r{^VERSION INFOSEC (?<name>\p{Print}+)\s+P/N (?<partno>\S+)\s+REVISION (?<revision>\S+)}
hashify_captures_into( $~, data[:infosec] )
when %r{^VERSION SW (?<name>\S+)\s+P/N (?<partno>\S+)\s+REVISION (?<revision>.+?)\s+SW_REV (\S+)}
hashify_captures_into( $~, data[:sw] )
when /^VERSION SYSTEM SW\s+REVISION (\S+ \S+)/
data[:system] = $1
else
self.log.debug "Unhandled software version line: %p" % [ line ]
end
end
message = Ravn::HAL::Message.new( 'sys.radio.versions', data: )
self.filter_up( message )
end
parse_into_volume_event( lines )
Parse the given RT VOLUME output lines
and emit an event describing the transceiver’s volume level.
def parse_into_volume_event( lines )
radio = self.class.object_id
transceiver = lines.first[ /^RT([12])/i, 1 ] or
raise "failed to match transceiver number from %p" % [ lines.first ]
transceiver = transceiver.to_i
data = lines.each_with_object({ radio:, transceiver: }) do |line, hash|
case line
when IGNORED_LINE
next
when /^RT[12] VOLUME (\d+|MIN|MAX)/
level = case $1
when 'MIN' then 0
when 'MAX' then 10
else
$1.to_i
end
hash[:level] = level
else
self.log.debug "Unhandled transceiver state line: %p" % [ line ]
end
end
message = Ravn::HAL::Message.new( 'sys.radio.volume', data: )
self.filter_up( message )
end
queue_command( *commands )
queue_commands( *commands )
Add new commands
to the write queue.
def queue_commands( *commands )
self.write_queue.push( *commands )
end
queue_periodic_commands( * )
Timer callback: queue commands for events that should be emitted periodically.
def queue_periodic_commands( * )
self.request_gps_info
self.request_rt_info
end
Split off complete lines from the read_buffer
and append then to the read queue.
def queue_read_lines
self.log.debug "Queueing read lines..."
if (index = self.read_buffer.rindex( COMMAND_PROMPT ))
index += 6
self.log.debug " reading lines up to index %d" % [ index ]
if (line_data = self.read_buffer.slice!( 0, index ))
self.log.debug " read: %p" % [ line_data ]
line_data.each_line do |line|
line.chomp!
if OOB_MESSAGE_LINE.match?( line )
self.handle_oob_status_line( line )
else
self.read_queue.push( line )
end
end
self.log.debug " there are now %d queued lines" % [ self.read_queue.length ]
else
self.log.warn "no line data! (0..%d of %p)" % [ index, self.read_buffer ]
end
else
self.log.debug "No complete lines in %d bytes of data; skipping." %
[ self.read_buffer.bytesize ]
end
end
Fetch the radio device, creating it if it wasn’t already.
def radio
return @radio ||= self.build_radio_connection
end
Wait on the radio to be readable and/or writable and handle reading and writing when it’s ready.
def read_and_write
radio_dev = self.radio or raise "Couldn't connect to the radio"
ios = [ radio_dev ]
readable = ios
self.buffer_pending_writes
writable = self.write_buffer.empty? ? nil : ios
if (ready = IO.select( readable, writable, nil, IO_SELECT_TIMEOUT ))
if ready[ 0 ]
self.read_from_radio
self.queue_read_lines
self.parse_and_emit_events
end
if ready[ 1 ]
self.write_to_radio
end
end
rescue IO::WaitReadable, IO::WaitWritable => err
self.log.debug "%p while reading/writing to the radio: %s" % [ err.class, err.message ]
end
Read output from the radio and generate events from it. It propagates errors from the read to its caller, notably IO::WaitReadable exceptions and EOFError.
def read_from_radio
if (data = self.radio&.read_nonblock( MAX_READ_SIZE ))
self.log.debug "Read %d bytes from the radio" % [ data.bytesize ]
self.read_buffer << data
end
end
Queue up the command to emit battery info events.
def request_battery_info
self.queue_command( 'BATTERY' )
end
Queue up the command to emit GPS info events.
def request_gps_info
self.queue_command( 'GPS INFO' )
end
request_next_preset( transceiver )
Queue up the command to change the given transceiver
‘s preset to the next one.
def request_next_preset( transceiver )
command = "RT%d SYS_PRESET SELECT NEXT" % [ transceiver ]
self.queue_command( command )
end
request_previous_preset( transceiver )
Queue up the command to change the given transceiver
‘s preset to the previous one.
def request_previous_preset( transceiver )
command = "RT%d SYS_PRESET SELECT PREVIOUS" % [ transceiver ]
self.queue_command( command )
end
request_rt_change( message )
def request_rt_change( message )
target_id = message.data[ :radio ]
unless target_id == self.identifier
self.log.debug "Ignoring `command` event for radio %p" % [ target_id ]
return
end
transceiver = message.data[ :transceiver ]
unless transceiver&.between?( 1, 2 )
self.log.error "Invalid/unset transceiver (%p) for message %p" % [ transceiver, message ]
return
end
case message.data[ :command ]
when 'channel up'
self.request_next_preset( transceiver )
when 'channel down'
self.request_previous_preset( transceiver )
when 'volume set'
self.request_volume_set( transceiver, message )
when nil
self.log.warn "No `command` specified for %p" % [ message ]
else
self.log.warn "Command %p not implemented." % [ message.data[:command] ]
end
end
Queue commands that will emit events describing the status of the radio transmitters (preset, volume, etc.).
def request_rt_info
self.queue_command( "RT1 STATE ALL" )
self.queue_command( "RT1 VOLUME" )
self.queue_command( "RT2 STATE ALL" )
self.queue_command( "RT2 VOLUME" )
end
Queue up the command to emit version info events.
def request_version_info
self.queue_command( 'VERSION ALL' )
end
request_volume_set( transceiver, message )
Queue up the command to change the given transceiver
‘s volume to the level specified by the message
.
def request_volume_set( transceiver, message )
if (level = message.data[ :level ])
unless level.between?( 0, 12 )
self.log.warn "Ignoring request to set volume to %p (0 <= level <=12)" %
[ level ]
return
end
else
self.log.error "No `level` given for `volume set` command: %p" % [ message ]
return
end
command = "RT%d VOLUME %d" % [ transceiver, level ]
self.queue_command( command )
end
Reset the device.
def reset( reason )
self.log.error( reason )
sleep( RESET_THROTTLE_TIME )
self.core&.parent&.tell( reset_device: self )
end
Fire up an interval timer for radio messages.
def start
super
self.periodic_command_timer.execute
self.request_version_info
@io_thread = self.start_io_loop unless self.class.simulation_mode?
end
Start reading and writing from/to the radio. Emits events for information read, and writes commands from the write_queue
if there are any. Returns a Thread that is running the loop.
def start_io_loop
return Thread.new( &self.method(:io_loop) )
end
Write any pending commands to the radio.
def write_to_radio
return if self.write_buffer.empty?
self.log.debug "Trying to write %d bytes to the radio" % [ self.write_buffer.bytesize ]
bytes = self.radio&.write_nonblock( self.write_buffer )
if bytes&.nonzero?
self.log.debug "wrote %d bytes to the radio" % [ bytes ]
self.write_buffer.slice!( 0, bytes )
end
end
Protected Instance Methods
make_periodic_command_timer()
Build a Concurrent::TimerTask that will read output from the radio and generate events from it.
def make_periodic_command_timer
timer = Concurrent::TimerTask.new { self.queue_periodic_commands }
timer.execution_interval = self.class.status_frequency
timer.add_observer( Ravn::LoggingTaskObserver.new(:prc163_periodic_command_timer) )
return timer
end