onkyo_eiscp_ruby 0.0.2 → 2.1.1

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.
data/lib/eiscp.rb CHANGED
@@ -1,10 +1,12 @@
1
- # Create and send EISCP messages to control Onkyo receivers.
1
+ # frozen_string_literal: true
2
2
 
3
- class EISCP
4
- VERSION = '0.0.2'
3
+ # Library for controlling Onkyo receivers over TCP/IP.
4
+ #
5
+ module EISCP
6
+ VERSION = '2.1.1'
5
7
  end
6
8
 
7
- require 'eiscp/eiscp'
8
- require 'eiscp/eiscp_packet'
9
- require 'eiscp/iscp_message'
10
- require 'eiscp/command'
9
+ require_relative './eiscp/receiver'
10
+ require_relative './eiscp/message'
11
+ require_relative './eiscp/dictionary'
12
+ require_relative './eiscp/parser'
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './dictionary/dictionary_generators'
4
+ require_relative './dictionary/dictionary_helpers'
5
+
6
+ module EISCP
7
+ # This module provides an interface to the information from the yaml file. It
8
+ # uses DictionaryGenerators to add commands specified by ranges in the yaml
9
+ # file. It uses DictionaryHelpers to convert commands and values to and from
10
+ # their human readable form.
11
+ #
12
+ module Dictionary
13
+ extend DictionaryGenerators
14
+ extend DictionaryHelpers
15
+
16
+ class << self
17
+ attr_reader :zones
18
+ attr_reader :modelsets
19
+ attr_reader :commands
20
+ end
21
+
22
+ DEFAULT_ZONE = 'main'
23
+ @yaml_file_path = File.join(__dir__, '../../eiscp-commands.yaml')
24
+ @commands = YAML.load(File.read(@yaml_file_path))
25
+ @modelsets = @commands[:modelsets]
26
+ @commands.delete(:modelsets)
27
+ @zones = @commands.map { |k, _| k }
28
+
29
+ @additions = []
30
+ @commands.each_key do |zone|
31
+ @commands[zone].each do |command|
32
+ command = command[0]
33
+ @commands[zone][command][:values].each do |value|
34
+ value = value[0]
35
+ if value.is_a? Array
36
+ @additions << [zone, command, value, create_range_commands(zone, command, value)]
37
+ elsif value.match(/^(B|T){xx}$/)
38
+ @additions << [zone, command, value, create_treble_bass_commands(zone, command, value)]
39
+ elsif value.match(/^{xx}$/)
40
+ @additions << [zone, command, value, create_balance_commands(zone, command, value)]
41
+ else
42
+ next
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ @additions.each do |zone, command, value, hash|
49
+ @commands[zone][command][:values].merge! hash
50
+ rescue StandardError
51
+ puts "Failed to add #{hash} to #{zone}:#{command}:#{value}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module EISCP
6
+ module Dictionary
7
+ # This module provides methods that can be used to generate command entries
8
+ # that are specified as ranges in the yaml file.
9
+ #
10
+ module DictionaryGenerators
11
+ # Creates a hash object for range commands like master-volume
12
+ #
13
+ def create_range_commands(zone, command, value)
14
+ case value.count
15
+ when 3
16
+ range = Range.new(value[0], value[2])
17
+ when 2
18
+ range = Range.new(*value)
19
+ end
20
+ tmp = {}
21
+ range.each do |number|
22
+ tmp.merge!(number.to_s(16).rjust(2, '0').upcase =>
23
+ {
24
+ name: number.to_s,
25
+ description: @commands[zone][command][:values][value][:description].gsub(/\d - \d+/, number.to_s),
26
+ models: @commands[zone][command][:values][value][:models]
27
+ })
28
+ end
29
+ 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: @commands[zone][command][:values][value[0] + '{xx}'][:description].gsub(/\(.*[\]|\)]$/, v),
41
+ models: @commands[zone][command][:values][value[0] + '{xx}'][:models]
42
+ })
43
+ end
44
+ tmp
45
+ end
46
+
47
+ # Creates hash object for balance commands
48
+ #
49
+ def create_balance_commands(zone, command, _value)
50
+ tmp = {}
51
+ ['-A', '-8', '-6', '-4', '-2', '00', '+2', '+4', '+6', '+8', '+A'].each do |v|
52
+ tmp.merge!(v.to_s =>
53
+ {
54
+ name: v.downcase,
55
+ description: @commands[zone][command][:values]['{xx}'][:description].gsub(/\(.*[\]|\)]$/, v),
56
+ models: @commands[zone][command][:values]['{xx}'][:models]
57
+ })
58
+ end
59
+ tmp
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EISCP
4
+ module Dictionary
5
+ # This module provides methods to get information from the Dictionary about
6
+ # commands, values, zones, and models.
7
+ #
8
+ module DictionaryHelpers
9
+ # Return the zone that includes the given command
10
+ def zone_from_command(command)
11
+ @zones.each do |zone|
12
+ @commands[zone].each_pair do |k, _|
13
+ return zone if command == k
14
+ end
15
+ end
16
+ nil
17
+ end
18
+
19
+ # Return the human readable name of a command
20
+ def command_to_name(command)
21
+ command = command.upcase
22
+ begin
23
+ zone = zone_from_command(command)
24
+ @commands[zone][command][:name]
25
+ rescue StandardError
26
+ nil
27
+ end
28
+ end
29
+
30
+ # Return the command from a given command name
31
+ def command_name_to_command(name, command_zone = nil)
32
+ if command_zone.nil?
33
+
34
+ @zones.each do |zone|
35
+ @commands[zone].each_pair do |command, attrs|
36
+ return command if attrs[:name] == name
37
+ end
38
+ end
39
+ nil
40
+
41
+ else
42
+
43
+ @commands[command_zone].each_pair do |command, attrs|
44
+ return command if attrs[:name] == name
45
+ end
46
+ nil
47
+
48
+ end
49
+ end
50
+
51
+ # Return a value name from a command and a value
52
+ def command_value_to_value_name(command, value)
53
+ zone = zone_from_command(command)
54
+ @commands[zone][command][:values][value][:name]
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # Return a value from a command and value name
60
+ def command_value_name_to_value(command, value_name)
61
+ zone = zone_from_command(command)
62
+ @commands[zone][command][:values].each_pair do |k, v|
63
+ if v[:name].class == String
64
+ return k if v[:name] == value_name.to_s
65
+ elsif v[:name].class == Array
66
+ return k if v[:name].first == value_name.to_s
67
+ end
68
+ end
69
+ return nil
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ # Return a command description from a command name and zone
75
+ def description_from_command_name(name, zone)
76
+ @commands[zone].each_pair do |command, attrs|
77
+ return @commands[zone][command][:description] if attrs[:name] == name
78
+ end
79
+ nil
80
+ end
81
+
82
+ # Return a command description from a command
83
+ def description_from_command(command)
84
+ zone = zone_from_command(command)
85
+ @commands[zone][command][:description]
86
+ end
87
+
88
+ # Return a value description from a command and value
89
+ def description_from_command_value(command, value)
90
+ zone = zone_from_command(command)
91
+ @commands[zone][command][:values].select do |k, v|
92
+ return v[:description] if k == value
93
+ end
94
+ nil
95
+ end
96
+
97
+ # Return a list of commands compatible with a given model
98
+ def list_compatible_commands(modelstring)
99
+ sets = []
100
+ @modelsets.each_pair do |set, array|
101
+ sets << set if array.include? modelstring
102
+ end
103
+ sets
104
+ end
105
+
106
+ # Checks to see if the command is in the Dictionary
107
+ #
108
+ def known_command?(command)
109
+ zone = zone_from_command(command)
110
+ @commands[zone].include? command
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './dictionary'
4
+ require_relative './parser'
5
+
6
+ module EISCP
7
+ # The EISCP::Message class is used to handle commands and responses.
8
+ #
9
+ # Messages can be parsed directly from raw data or created with values:
10
+ # receiver = Receiver.new
11
+ #
12
+ # command = EISCP::Message.new('PWR', 'QSTN')
13
+ # response = EISCP::Parser.parse(receiver.send_recv(command))
14
+ #
15
+ class Message
16
+ # EISCP header
17
+ attr_accessor :header
18
+ # ISCP "magic" indicates the start of an eISCP message.
19
+ MAGIC = 'ISCP'
20
+ # eISCP header size, fixed length.
21
+ HEADER_SIZE = 16
22
+ # ISCP protocol version.
23
+ ISCP_VERSION = 1
24
+ # Reserved for future protocol updates.
25
+ RESERVED = "\x00\x00\x00"
26
+
27
+ # ISCP Start character, usually "!"
28
+ attr_reader :start
29
+ # ISCP Unit Type character, usually "1"
30
+ attr_reader :unit_type
31
+ # ISCP Command
32
+ attr_reader :command
33
+ # Human readable command name
34
+ attr_reader :command_name
35
+ # Command description
36
+ attr_reader :command_description
37
+ # ISCP Command Value
38
+ attr_reader :value
39
+ # Human readable value name
40
+ attr_reader :value_name
41
+ # Value description
42
+ attr_reader :value_description
43
+ # ISCP Zone
44
+ attr_reader :zone
45
+ # Differentiates parsed messages from command messages
46
+ attr_reader :parsed
47
+
48
+ # Terminator character for eISCP packets
49
+ attr_reader :terminator
50
+
51
+ # Create an ISCP message
52
+ # @param [String] command three-character length ISCP command
53
+ # @param [String] value variable length ISCP command value
54
+ # @param [String] unit_type_character override default unit type character, optional
55
+ # @param [String] start_character override default start character, optional
56
+ def initialize(command: nil, value: nil, terminator: "\r\n", unit_type: '1', start: '!')
57
+ unless Dictionary.known_command?(command)
58
+ # STDERR.puts "Unknown command #{command}"
59
+ end
60
+
61
+ raise 'No value specified.' if value.nil?
62
+
63
+ @command = command
64
+ @value = value
65
+ @terminator = terminator
66
+ @unit_type = unit_type
67
+ @start = start
68
+ @header = { magic: MAGIC,
69
+ header_size: HEADER_SIZE,
70
+ data_size: to_iscp.length,
71
+ version: ISCP_VERSION,
72
+ reserved: RESERVED }
73
+ begin
74
+ get_human_readable_attrs
75
+ rescue StandardError
76
+ # STDERR.puts"Couldn't get all human readable attrs"
77
+ end
78
+ end
79
+
80
+ # Check if two messages are equivalent comparing their ISCP messages.
81
+ #
82
+ def ==(other)
83
+ to_iscp == other.to_iscp
84
+ end
85
+
86
+ # Return ISCP Message string
87
+ #
88
+ def to_iscp
89
+ (@start + @unit_type + @command + @value).to_s
90
+ end
91
+
92
+ # Return EISCP Message string
93
+ #
94
+ def to_eiscp
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}"
110
+ end
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
122
+ end
123
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './parser/eiscp_parser'
4
+ require_relative './parser/iscp_parser'
5
+ require_relative './parser/human_readable_parser'
6
+
7
+ module EISCP
8
+ # This module provides an interface to the other parser modules.
9
+ #
10
+ module Parser
11
+ # Passes the string to the proper parser module's #parse method.
12
+ #
13
+ def self.parse(string)
14
+ case string
15
+ when /^ISCP/
16
+ EISCPParser.parse(string)
17
+ when ISCPParser::REGEX
18
+ ISCPParser.parse(string)
19
+ else
20
+ HumanReadableParser.parse(string)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EISCP
4
+ module Parser
5
+ module DynamicValueParser
6
+ # Still trying to sort this out.
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './iscp_parser'
4
+
5
+ module EISCP
6
+ module Parser
7
+ # This module parses an eISCP string and returns a Message object
8
+ #
9
+ module EISCPParser
10
+ def self.parse(string)
11
+ array = string.unpack('A4NNCa3A*')
12
+ msg = ISCPParser.parse(array[5])
13
+ packet = Message.new(
14
+ command: msg.command,
15
+ value: msg.value,
16
+ terminator: msg.terminator,
17
+ unit_type: msg.unit_type,
18
+ start: msg.start
19
+ )
20
+ packet.header = {
21
+ magic: array[0],
22
+ header_size: array[1],
23
+ data_size: array[2],
24
+ version: array[3],
25
+ reserved: array[4]
26
+ }
27
+ packet
28
+ end
29
+
30
+ def self.validate(packet)
31
+ packet.header.header_size.size == packet.command.size
32
+ end
33
+ end
34
+
35
+ class EISCPParserException < RuntimeError; end
36
+ end
37
+ end