onkyo_eiscp_ruby 0.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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