onkyo_eiscp_ruby 0.0.3 → 1.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,67 @@
1
+ require 'yaml'
2
+
3
+ module EISCP
4
+ module Dictionary
5
+ # This module provides methods that can be used to generate command entries
6
+ # that are specified as ranges in the yaml file.
7
+ #
8
+ module DictionaryGenerators
9
+ # Creates a hash object for range commands like master-volume
10
+ #
11
+ def create_range_commands(zone, command, value)
12
+ case value.count
13
+ when 3
14
+ range = Range.new(value[0], value[2])
15
+ when 2
16
+ range = Range.new(*value)
17
+ end
18
+ tmp = {}
19
+ range.each do |number|
20
+ tmp.merge!(number.to_s(16).rjust(2, '0').upcase =>
21
+ {
22
+ :name => number.to_s,
23
+ :description =>
24
+ @commands[zone][command][:values][value][:description].gsub(/\d - \d+/, number.to_s),
25
+ :models => @commands[zone][command][:values][value][:models]
26
+ }
27
+ )
28
+ end
29
+ return tmp
30
+ end
31
+
32
+ # Creates hash object for treble and bass commands
33
+ #
34
+ def create_treble_bass_commands(zone, command, value)
35
+ tmp = {}
36
+ ['-A', '-8', '-6', '-4', '-2', '00', '+2', '+4', '+6', '+8', '+A'].each do |v|
37
+ tmp.merge!((value[0] + v.to_s) =>
38
+ {
39
+ :name => value[0].downcase + v,
40
+ :description =>
41
+ @commands[zone][command][:values][value[0] + '{xx}'][:description].gsub(/\(.*[\]|\)]$/, v),
42
+ :models => @commands[zone][command][:values][value[0] + '{xx}'][:models]
43
+ }
44
+ )
45
+ end
46
+ return tmp
47
+ end
48
+
49
+ # Creates hash object for balance commands
50
+ #
51
+ def create_balance_commands(zone, command, _value)
52
+ tmp = {}
53
+ ['-A', '-8', '-6', '-4', '-2', '00', '+2', '+4', '+6', '+8', '+A'].each do |v|
54
+ tmp.merge!(v.to_s =>
55
+ {
56
+ :name => v.downcase,
57
+ :description =>
58
+ @commands[zone][command][:values]['{xx}'][:description].gsub(/\(.*[\]|\)]$/, v),
59
+ :models => @commands[zone][command][:values]['{xx}'][:models]
60
+ }
61
+ )
62
+ end
63
+ return tmp
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,118 @@
1
+ module EISCP
2
+ module Dictionary
3
+ # This module provides methods to get information from the Dictionary about
4
+ # commands, values, zones, and models.
5
+ #
6
+ module DictionaryHelpers
7
+ # Return the zone that includes the given command
8
+ def zone_from_command(command)
9
+ @zones.each do |zone|
10
+ @commands[zone].each_pair do |k, _|
11
+ return zone if command == k
12
+ end
13
+ end
14
+ nil
15
+ end
16
+
17
+ # Return the human readable name of a command
18
+ def command_to_name(command)
19
+ command = command.upcase
20
+ begin
21
+ zone = zone_from_command(command)
22
+ return @commands[zone][command][:name]
23
+ rescue
24
+ return nil
25
+ end
26
+ end
27
+
28
+ # Return the command from a given command name
29
+ def command_name_to_command(name, command_zone = nil)
30
+ if command_zone.nil?
31
+
32
+ @zones.each do |zone|
33
+ @commands[zone].each_pair do |command, attrs|
34
+ return command if attrs[:name] == name
35
+ end
36
+ end
37
+ return nil
38
+
39
+ else
40
+
41
+ @commands[command_zone].each_pair do |command, attrs|
42
+ return command if attrs[:name] == name
43
+ end
44
+ return nil
45
+
46
+ end
47
+ end
48
+
49
+ # Return a value name from a command and a value
50
+ def command_value_to_value_name(command, value)
51
+ begin
52
+ zone = zone_from_command(command)
53
+ @commands[zone][command][:values][value][:name]
54
+ rescue
55
+ nil
56
+ end
57
+ end
58
+
59
+ # Return a value from a command and value name
60
+ def command_value_name_to_value(command, value_name)
61
+ begin
62
+ zone = zone_from_command(command)
63
+ @commands[zone][command][:values].each_pair do |k, v|
64
+ return k if v[:name] == value_name.to_s
65
+ end
66
+ rescue
67
+ nil
68
+ end
69
+ end
70
+
71
+ # Return a command description from a command name and zone
72
+ def description_from_command_name(name, zone)
73
+ @commands[zone].each_pair do |command, attrs|
74
+ if attrs[:name] == name
75
+ return @commands[zone][command][:description]
76
+ end
77
+ end
78
+ nil
79
+ end
80
+
81
+ # Return a command description from a command
82
+ def description_from_command(command)
83
+ zone = zone_from_command(command)
84
+ @commands[zone][command][:description]
85
+ end
86
+
87
+ # Return a value description from a command and value
88
+ def description_from_command_value(command, value)
89
+ zone = zone_from_command(command)
90
+ @commands[zone][command][:values].select do |k, v|
91
+ return v[:description] if k == value
92
+ end
93
+ nil
94
+ end
95
+
96
+ # Return a list of commands compatible with a given model
97
+ def list_compatible_commands(modelstring)
98
+ sets = []
99
+ @modelsets.each_pair do |set, array|
100
+ sets << set if array.include? modelstring
101
+ end
102
+ sets
103
+ end
104
+
105
+ # Checks to see if the command is in the Dictionary
106
+ #
107
+ def known_command?(command)
108
+ begin
109
+ zone = zone_from_command(command)
110
+ @commands[zone].include? command
111
+ rescue
112
+ return nil
113
+ end
114
+ end
115
+ end
116
+
117
+ end
118
+ end
@@ -0,0 +1,54 @@
1
+ require_relative './dictionary/dictionary_generators'
2
+ require_relative './dictionary/dictionary_helpers'
3
+
4
+ module EISCP
5
+ # This module provides an interface to the information from the yaml file. It
6
+ # uses DictionaryGenerators to add commands specified by ranges in the yaml
7
+ # file. It uses DictionaryHelpers to convert commands and values to and from
8
+ # their human readable form.
9
+ #
10
+ module Dictionary
11
+ extend DictionaryGenerators
12
+ extend DictionaryHelpers
13
+
14
+ class << self
15
+ attr_reader :zones
16
+ attr_reader :modelsets
17
+ attr_reader :commands
18
+ end
19
+
20
+ DEFAULT_ZONE = 'main'
21
+ @yaml_file_path = File.join(File.expand_path(File.dirname(__FILE__)), '../../eiscp-commands.yaml')
22
+ @commands = YAML.load(File.read(@yaml_file_path))
23
+ @modelsets = @commands[:modelsets]
24
+ @commands.delete(:modelsets)
25
+ @zones = @commands.map { |k, _| k }
26
+
27
+ @additions = []
28
+ @commands.each_key do |zone|
29
+ @commands[zone].each do |command|
30
+ command = command[0]
31
+ @commands[zone][command][:values].each do |value|
32
+ value = value[0]
33
+ if value.is_a? Array
34
+ @additions << [zone, command, value, create_range_commands(zone, command, value)]
35
+ elsif value.match(/^(B|T){xx}$/)
36
+ @additions << [zone, command, value, create_treble_bass_commands(zone, command, value)]
37
+ elsif value.match(/^{xx}$/)
38
+ @additions << [zone, command, value, create_balance_commands(zone, command, value)]
39
+ else
40
+ next
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ @additions.each do |zone, command, value, hash|
47
+ begin
48
+ @commands[zone][command][:values].merge! hash
49
+ rescue
50
+ puts "Failed to add #{hash} to #{zone}:#{command}:#{value}"
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/eiscp/message.rb CHANGED
@@ -1,99 +1,123 @@
1
+ # encoding: utf-8
2
+ require_relative './dictionary'
3
+ require_relative './parser'
4
+
1
5
  module EISCP
6
+ # The EISCP::Message class is used to handle commands and responses.
7
+ #
8
+ # Messages can be parsed directly from raw data or created with values:
9
+ # receiver = Receiver.new
10
+ #
11
+ # command = EISCP::Message.new('PWR', 'QSTN')
12
+ # response = EISCP::Parser.parse(receiver.send_recv(command))
13
+ #
2
14
  class Message
3
-
4
15
  # EISCP header
5
16
  attr_accessor :header
6
- MAGIC = "ISCP"
17
+ # ISCP "magic" indicates the start of an eISCP message.
18
+ MAGIC = 'ISCP'
19
+ # eISCP header size, fixed length.
7
20
  HEADER_SIZE = 16
8
- ISCP_VERSION = "\x01"
21
+ # ISCP protocol version.
22
+ ISCP_VERSION = 1
23
+ # Reserved for future protocol updates.
9
24
  RESERVED = "\x00\x00\x00"
10
25
 
26
+ # ISCP Start character, usually "!"
27
+ attr_reader :start
28
+ # ISCP Unit Type character, usually "1"
29
+ attr_reader :unit_type
30
+ # ISCP Command
31
+ attr_reader :command
32
+ # Human readable command name
33
+ attr_reader :command_name
34
+ # Command description
35
+ attr_reader :command_description
36
+ # ISCP Command Value
37
+ attr_reader :value
38
+ # Human readable value name
39
+ attr_reader :value_name
40
+ # Value description
41
+ attr_reader :value_description
42
+ # ISCP Zone
43
+ attr_reader :zone
44
+ # Differentiates parsed messages from command messages
45
+ attr_reader :parsed
46
+
47
+ # Terminator character for eISCP packets
48
+ attr_reader :terminator
49
+
50
+ # Create an ISCP message
51
+ # @param [String] command three-character length ISCP command
52
+ # @param [String] value variable length ISCP command value
53
+ # @param [String] unit_type_character override default unit type character, optional
54
+ # @param [String] start_character override default start character, optional
55
+ def initialize(command: nil, value: nil, terminator: "\r\n", unit_type: '1', start: '!')
56
+ unless Dictionary.known_command?(command)
57
+ warn "Unknown command #{command}"
58
+ end
11
59
 
12
- # ISCP attrs
13
- attr_accessor :start
14
- attr_accessor :unit_type
15
- attr_accessor :command
16
- attr_accessor :parameter
17
- attr_reader :iscp_message
18
-
19
-
20
- # REGEX
21
- REGEX = /(?<start>!)?(?<unit_type>(\d|x))?(?<command>[A-Z]{3})\s?(?<parameter>.*)(?<end>\x1A)?/
60
+ fail 'No value specified.' if value.nil?
22
61
 
23
- def initialize(command, parameter, unit_type = "1", start = "!")
24
- if unit_type == nil
25
- @unit_type = "1"
26
- else
27
- @unit_type = unit_type
28
- end
29
- if start == nil
30
- @start = "!"
31
- else
32
- @start = start
33
- end
34
62
  @command = command
35
- @parameter = parameter
36
- @iscp_message = [ @start, @unit_type, @command, @parameter ].inject(:+)
37
- @header = { :magic => MAGIC,
38
- :header_size => HEADER_SIZE,
39
- :data_size => @iscp_message.length,
40
- :version => ISCP_VERSION,
41
- :reserved => RESERVED
63
+ @value = value
64
+ @terminator = terminator
65
+ @unit_type = unit_type
66
+ @start = start
67
+ @header = { magic: MAGIC,
68
+ header_size: HEADER_SIZE,
69
+ data_size: to_iscp.length,
70
+ version: ISCP_VERSION,
71
+ reserved: RESERVED
42
72
  }
43
- end
44
-
45
-
46
- # Check if two messages send the same command
47
- def ==(message_object)
48
- self.iscp_message == message_object.iscp_message ? true : false
49
- end
50
- # Identifies message format, calls appropriate parse function
51
- # returns Message object.
52
-
53
- def self.parse(string)
54
- case string
55
- when /^ISCP/
56
- parse_eiscp_string(string)
57
- when REGEX
58
- parse_iscp_message(string)
59
- else
60
- puts "Not a valid ISCP or EISCP message."
73
+ begin
74
+ get_human_readable_attrs
75
+ rescue
76
+ warn "Couldn't get all human readable attrs"
61
77
  end
62
78
  end
63
79
 
64
-
65
-
66
- # ISCP Message string parser
67
-
68
- def self.parse_iscp_message(msg_string)
69
- match = msg_string.match(REGEX)
70
- Message.new(match[:command], match[:parameter], match[:unit_type], match[:start])
71
- end
72
-
73
- #parse eiscp_message string
74
- def self.parse_eiscp_string(eiscp_message_string)
75
- array = eiscp_message_string.unpack("A4NNAa3A*")
76
- iscp_message = Message.parse_iscp_message(array[5])
77
- packet = Message.new(iscp_message.command, iscp_message.parameter, iscp_message.unit_type, iscp_message.start)
78
- packet.header = {
79
- :magic => array[0],
80
- :header_size => array[1],
81
- :data_size => array[2],
82
- :version => array[3],
83
- :reserved => array[4]
84
- }
85
- return packet
80
+ # Check if two messages are equivalent comparing their ISCP messages.
81
+ #
82
+ def ==(other)
83
+ to_iscp == other.to_iscp ? true : false
86
84
  end
87
85
 
88
86
  # Return ISCP Message string
87
+ #
89
88
  def to_iscp
90
- return "#{@start + @unit_type + @command + @parameter}"
89
+ "#{@start + @unit_type + @command + @value}"
91
90
  end
92
91
 
93
92
  # Return EISCP Message string
93
+ #
94
94
  def to_eiscp
95
- return [ @header[:magic], @header[:header_size], @header[:data_size], @header[:version], @header[:reserved], @iscp_message.to_s ].pack("A4NNAa3A*")
95
+ [
96
+ @header[:magic],
97
+ @header[:header_size].to_i,
98
+ @header[:data_size].to_i,
99
+ @header[:version].to_i,
100
+ @header[:reserved],
101
+ to_iscp.to_s,
102
+ @terminator
103
+ ].pack('A4NNCa3A*A*')
104
+ end
105
+
106
+ # Return human readable description.
107
+ #
108
+ def to_s
109
+ "#{@zone} - #{@command_name}:#{@value_name}"
96
110
  end
97
111
 
112
+ private
113
+
114
+ # Retrieves human readable attributes from the yaml file via Dictionary
115
+ def get_human_readable_attrs
116
+ @zone = Dictionary.zone_from_command(@command)
117
+ @command_name = Dictionary.command_to_name(@command)
118
+ @command_description = Dictionary.description_from_command(@command)
119
+ @value_name = Dictionary.command_value_to_value_name(@command, @value)
120
+ @value_description = Dictionary.description_from_command_value(@command, @value)
121
+ end
98
122
  end
99
123
  end
@@ -0,0 +1,7 @@
1
+ module EISCP
2
+ module Parser
3
+ module DynamicValueParser
4
+ # Still trying to sort this out.
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,37 @@
1
+ require_relative '../message'
2
+ require_relative './iscp_parser'
3
+
4
+ module EISCP
5
+ module Parser
6
+ # This module parses an eISCP string and returns a Message object
7
+ #
8
+ module EISCPParser
9
+ def self.parse(string)
10
+ array = string.unpack('A4NNCa3A*')
11
+ msg = ISCPParser.parse(array[5])
12
+ packet = Message.new(
13
+ command: msg.command,
14
+ value: msg.value,
15
+ terminator: msg.terminator,
16
+ unit_type: msg.unit_type,
17
+ start: msg.start
18
+ )
19
+ packet.header = {
20
+ magic: array[0],
21
+ header_size: array[1],
22
+ data_size: array[2],
23
+ version: array[3],
24
+ reserved: array[4]
25
+ }
26
+ packet
27
+ end
28
+
29
+ def self.validate(packet)
30
+ packet.header.header_size.size == packet.command.size
31
+ end
32
+ end
33
+
34
+ class EISCPParserException < Exception; end
35
+
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ require_relative '../message'
2
+ require_relative '../dictionary'
3
+
4
+ module EISCP
5
+ module Parser
6
+ # This module parses a human readable command and returns a Message object
7
+ #
8
+ module HumanReadableParser
9
+ def self.parse(string)
10
+ array = string.split(' ')
11
+
12
+ if Dictionary.zones.include? array[0]
13
+ parsed_zone = array.shift
14
+ else
15
+ parsed_zone = Dictionary::DEFAULT_ZONE
16
+ end
17
+
18
+ command_name = array.shift
19
+ value_name = array.join(" ")
20
+ command = Dictionary.command_name_to_command(command_name, parsed_zone)
21
+ value = Dictionary.command_value_name_to_value(command, value_name)
22
+ if (command.nil? || value.nil?)
23
+ return nil
24
+ end
25
+ Message.new(command: command, value: value)
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ require_relative '../message'
2
+
3
+ module EISCP
4
+ module Parser
5
+ # This module parses an ISCP string and returns a Message object
6
+ #
7
+ module ISCPParser
8
+ # Regexp for parsing ISCP messages
9
+ REGEX = /(?<start>!)?(?<unit_type>(\d|x))?(?<command>[A-Z]{3})\s?(?<value>.*?)(?<terminator>[[:cntrl:]]*$)/
10
+ def self.parse(string)
11
+ match = string.match(REGEX)
12
+
13
+ # Convert MatchData to Hash
14
+ hash = Hash[match.names.zip(match.captures)]
15
+
16
+ # Remove nil and blank values
17
+ hash.delete_if { |_, v| v.nil? || v == '' }
18
+
19
+ # Convert keys to symbols
20
+ hash = hash.inject({}) do |memo, (k, v)|
21
+ memo[k.to_sym] = v
22
+ memo
23
+ end
24
+
25
+ Message.new(**hash)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ require_relative './parser/eiscp_parser'
2
+ require_relative './parser/iscp_parser'
3
+ require_relative './parser/human_readable_parser'
4
+
5
+ module EISCP
6
+ # This module provides an interface to the other parser modules. It identifies
7
+ # the type of string to be parsed and then passes the string off to the
8
+ # appropriate parser module.
9
+ #
10
+ module Parser
11
+ def self.parse(string)
12
+ case string
13
+ when /^ISCP/
14
+ EISCPParser.parse(string)
15
+ when ISCPParser::REGEX
16
+ ISCPParser.parse(string)
17
+ else
18
+ HumanReadableParser.parse(string)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ require_relative '../parser'
2
+ require_relative '../dictionary'
3
+
4
+ module EISCP
5
+ class Receiver
6
+ # Iterates through every available command and defines a method to call that
7
+ # command. It's intended to be used through Receiver and uses methods included
8
+ # by Receiver::Connection. Each method accepts a string that should match the
9
+ # human readable name of a valid value for that command.
10
+ #
11
+ module CommandMethods
12
+ def self.generate
13
+ Dictionary.zones.each do |zone|
14
+ Dictionary.commands[zone].each do |command, _values|
15
+ begin
16
+ command_name = Dictionary.command_to_name(command).to_s.gsub(/-/, '_')
17
+ define_method(command_name) do |v|
18
+ yield Parser.parse(command_name.gsub(/_/, '-') + ' ' + v)
19
+ end
20
+ rescue => e
21
+ puts e
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,83 @@
1
+ require 'socket'
2
+ require_relative '../parser'
3
+
4
+ module EISCP
5
+ class Receiver
6
+ # This module handles connecting, sending, and receiving for Receivers.
7
+ #
8
+ module Connection
9
+ # Receiver's connection socket
10
+ attr_reader :socket
11
+ # Receiver's connection thread
12
+ attr_reader :thread
13
+ # Most recent message received
14
+ attr_reader :last
15
+
16
+ # Default connection timeout value in seconds
17
+ DEFAULT_TIMEOUT = 0.5
18
+
19
+ # Default Onkyo eISCP port
20
+ ONKYO_PORT = 60_128
21
+
22
+ # Create a new connection thread. Also accepts a block that will run
23
+ # whenver a message is received. You can pass the Message object in with
24
+ # your block. This is the method #new uses to create the initial thread.
25
+ #
26
+ def update_thread
27
+ @thread && @thread.kill
28
+ @thread = Thread.new do
29
+ loop do
30
+ recv
31
+ yield(@last) if block_given?
32
+ end
33
+ end
34
+ end
35
+
36
+ # This handles the background thread for monitoring messages from the
37
+ # receiver.
38
+ #
39
+ # If a block is given, it can be used to setup a callback when a message
40
+ # is received.
41
+ #
42
+ def connect(host, port = ONKYO_PORT, &block)
43
+ begin
44
+ @socket = TCPSocket.new(host, port)
45
+ update_thread(&block)
46
+ rescue => e
47
+ puts e
48
+ end
49
+ end
50
+
51
+ # Sends an EISCP::Message object or string on the network
52
+ #
53
+ def send(eiscp)
54
+ if eiscp.is_a? EISCP::Message
55
+ @socket.puts(eiscp.to_eiscp)
56
+ elsif eiscp.is_a? String
57
+ @socket.puts eiscp
58
+ end
59
+ end
60
+
61
+ # Reads the socket and returns and EISCP::Message
62
+ #
63
+ def recv
64
+ message = ''
65
+ message << @socket.gets until message.match(/\r\n$/) do
66
+ @last = Parser.parse(message)
67
+ end
68
+ end
69
+
70
+ # Sends an EISCP::Message object or string on the network and returns recieved data string.
71
+ #
72
+ def send_recv(eiscp)
73
+ if eiscp.is_a? EISCP::Message
74
+ @socket.puts(eiscp.to_eiscp)
75
+ elsif eiscp.is_a? String
76
+ @socket.puts(eiscp)
77
+ end
78
+ sleep DEFAULT_TIMEOUT
79
+ last
80
+ end
81
+ end
82
+ end
83
+ end