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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../dictionary'
4
+
5
+ module EISCP
6
+ module Parser
7
+ # This module parses a human readable command and returns a Message object
8
+ #
9
+ module HumanReadableParser
10
+ def self.parse(string)
11
+ array = string.split(' ')
12
+
13
+ parsed_zone = if Dictionary.zones.include? array[0]
14
+ array.shift
15
+ else
16
+ Dictionary::DEFAULT_ZONE
17
+ end
18
+
19
+ command_name = array.shift
20
+ value_name = array.join(' ')
21
+ command = Dictionary.command_name_to_command(command_name, parsed_zone)
22
+ value = Dictionary.command_value_name_to_value(command, value_name)
23
+ return nil if command.nil? || value.nil?
24
+
25
+ Message.new(command: command, value: value)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
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:]]*$)/.freeze
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.each_with_object({}) do |(k, v), memo|
21
+ memo[k.to_sym] = v
22
+ end
23
+
24
+ Message.new(**hash)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require_relative './receiver/discovery'
5
+ require_relative './receiver/command_methods'
6
+
7
+ module EISCP
8
+ # The EISCP::Receiver class is used to communicate with one or more
9
+ # receivers the network. A Receiver can be instantiated automatically
10
+ # using discovery, or by hostname and port.
11
+ #
12
+ # receiver = EISCP::Receiver.new # find first receiver on LAN
13
+ # receiver = EISCP::Receiver.new('192.168.1.12') # default port
14
+ # receiver = EISCP::Receiver.new('192.168.1.12', 60129) # non standard port
15
+ #
16
+ class Receiver
17
+ extend Discovery
18
+ include CommandMethods
19
+
20
+ # Receiver's IP address
21
+ attr_accessor :host
22
+ # Receiver's model string
23
+ attr_accessor :model
24
+ # Receiver's ISCP port
25
+ attr_accessor :port
26
+ # Receiver's region
27
+ attr_accessor :area
28
+ # Receiver's MAC address
29
+ attr_accessor :mac_address
30
+
31
+ # State object
32
+ attr_accessor :state
33
+
34
+ # Receiver's connection socket
35
+ attr_reader :socket
36
+ # Receiver's connection thread
37
+ attr_reader :thread
38
+
39
+ # Default connection timeout value in seconds
40
+ DEFAULT_TIMEOUT = 0.5
41
+
42
+ # Default Onkyo eISCP port
43
+ ONKYO_PORT = 60_128
44
+
45
+ # Create a new EISCP::Receiver object to communicate with a receiver.
46
+ # If no host is given, use auto discovery and create a
47
+ # receiver object using the first host to respond.
48
+ #
49
+ def initialize(host = nil, info_hash = {}, &block)
50
+ # Initialize state
51
+ #
52
+ @state = {}
53
+ # This defines the behavior of CommandMethods by telling it what to do
54
+ # with the Message object that results from a CommandMethod being called.
55
+ # All we're doing here is calling #send_recv
56
+ #
57
+ command_method_proc = proc { |msg| send_recv msg }
58
+ CommandMethods.generate(&command_method_proc)
59
+
60
+ # This proc sets the four ECN attributes and initiates a connection to the
61
+ # receiver.
62
+ #
63
+ set_attrs = lambda do |hash|
64
+ @model = hash[:model]
65
+ @port = hash[:port]
66
+ @area = hash[:area]
67
+ @mac_address = hash[:mac_address]
68
+ connect(&block) if block_given?
69
+ end
70
+
71
+ # This lambda sets the host IP after resolving it
72
+ #
73
+ set_host = lambda do |hostname|
74
+ @host = Resolv.getaddress hostname
75
+ end
76
+
77
+ # When no host is given, the first discovered host is returned.
78
+ #
79
+ # When a host is given without a hash ::discover will be used to find
80
+ # a receiver that matches.
81
+ #
82
+ # Else, use the given host and hash to create a new Receiver object.
83
+ # This is how ::discover creates Receivers.
84
+ #
85
+ if host.nil?
86
+ first_found = Receiver.discover[0]
87
+ set_host.call first_found.host
88
+ set_attrs.call first_found.ecn_hash
89
+ elsif info_hash.empty?
90
+ set_host.call host
91
+ Receiver.discover.each do |receiver|
92
+ receiver.host == @host && set_attrs.call(receiver.ecn_hash)
93
+ end
94
+ else
95
+ set_host.call host
96
+ set_attrs.call info_hash
97
+ end
98
+ end
99
+
100
+ # Manages the thread and uses the same block passed to through #connect.
101
+ #
102
+ def update_thread
103
+ # Kill thread if it exists
104
+ thread && @thread.kill
105
+ @thread = Thread.new do
106
+ loop do
107
+ message = recv
108
+ @state[message.command] = message.value
109
+ yield(message) if block_given?
110
+ end
111
+ end
112
+ end
113
+ private :update_thread
114
+
115
+ # This creates a socket conection to the receiver if one doesn't exist,
116
+ # and updates or sets the callback block if one is passed.
117
+ #
118
+ def connect(&block)
119
+ @socket ||= TCPSocket.new(@host, @port)
120
+ update_thread(&block)
121
+ rescue StandardError => e
122
+ puts e
123
+ end
124
+
125
+ # Disconnect from the receiver by closing the socket and killing the
126
+ # connection thread.
127
+ #
128
+ def disconnect
129
+ @thread.kill
130
+ @socket.close
131
+ end
132
+
133
+ # Sends an EISCP::Message object or string on the network
134
+ #
135
+ def send(eiscp)
136
+ if eiscp.is_a? EISCP::Message
137
+ @socket.puts(eiscp.to_eiscp)
138
+ elsif eiscp.is_a? String
139
+ @socket.puts eiscp
140
+ end
141
+ end
142
+
143
+ # Reads the socket and returns and EISCP::Message
144
+ #
145
+ def recv
146
+ data = ''
147
+ data << @socket.gets until data.match(/\r\n$/)
148
+ message = Parser.parse(data)
149
+ message
150
+ end
151
+
152
+ # Sends an EISCP::Message object or string on the network and returns recieved data string.
153
+ #
154
+ def send_recv(eiscp)
155
+ eiscp = Parser.parse(eiscp) if eiscp.is_a? String
156
+ send eiscp
157
+ sleep DEFAULT_TIMEOUT
158
+ Parser.parse("#{eiscp.command}#{@state[eiscp.command]}")
159
+ end
160
+
161
+ # Return ECN hash with model, port, area, and MAC address
162
+ #
163
+ def ecn_hash
164
+ { model: @model,
165
+ port: @port,
166
+ area: @area,
167
+ mac_address: @mac_address }
168
+ end
169
+
170
+ # This will return a human-readable represantion of the receiver's state.
171
+ #
172
+ def human_readable_state
173
+ hash = {}
174
+ @state.each do |c, v|
175
+ hash[Dictionary.command_to_name(c).to_s] = (Dictionary.command_value_to_value_name(c, v) || v.to_s).to_s
176
+ end
177
+ hash
178
+ end
179
+
180
+ # Runs every command that supports the 'QSTN' value. This is a good way to
181
+ # get the sate of the receiver after connecting.
182
+ #
183
+ def update_state
184
+ Thread.new do
185
+ Dictionary.commands.each do |zone, _commands|
186
+ Dictionary.commands[zone].each do |command, info|
187
+ info[:values].each do |value, _|
188
+ next unless value == 'QSTN'
189
+
190
+ send(Parser.parse(command + 'QSTN'))
191
+ # If we send any faster we risk making the stereo drop replies.
192
+ # A dropped reply is not necessarily indicative of the
193
+ # receiver's failure to receive the command and change state
194
+ # accordingly. In this case, we're only making queries, so we do
195
+ # want to capture every reply.
196
+ sleep DEFAULT_TIMEOUT
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../parser'
4
+ require_relative '../dictionary'
5
+
6
+ module EISCP
7
+ class Receiver
8
+ # Iterates through every available command and defines a method to call that
9
+ # command. It's intended to be used through Receiver and uses methods included
10
+ # by Receiver::Connection. Each method accepts a string that should match the
11
+ # human readable name of a valid value for that command.
12
+ #
13
+ module CommandMethods
14
+ def self.generate(&block)
15
+ Dictionary.zones.each do |zone|
16
+ Dictionary.commands[zone].each do |command, _values|
17
+ command_name = Dictionary.command_to_name(command).to_s.gsub(/-/, '_')
18
+ define_method(command_name) do |v|
19
+ instance_exec Parser.parse(command_name.gsub(/_/, '-') + ' ' + v), &block
20
+ end
21
+ rescue StandardError => e
22
+ puts e
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require_relative '../message'
5
+ require_relative '../parser'
6
+
7
+ module EISCP
8
+ class Receiver
9
+ # This module discovers receivers on the local LAN.
10
+ #
11
+ module Discovery
12
+ # ISCP Magic Packet for Autodiscovery
13
+ ONKYO_MAGIC = Message.new(command: 'ECN', value: 'QSTN', terminator: "\r\n", unit_type: 'x').to_eiscp
14
+
15
+ # Populates Receiver attributes with info from ECNQSTN response.
16
+ #
17
+ def ecn_string_to_ecn_array(ecn_string)
18
+ hash = {}
19
+ message = Parser.parse(ecn_string)
20
+ array = message.value.split('/')
21
+ hash[:model] = array.shift
22
+ hash[:port] = array.shift.to_i
23
+ hash[:area] = array.shift
24
+ hash[:mac_address] = array.shift
25
+ hash
26
+ end
27
+
28
+ # Returns an array of discovered Receiver objects.
29
+ #
30
+ def discover(discovery_port = Receiver::ONKYO_PORT)
31
+ sock = UDPSocket.new
32
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
33
+ sock.send(ONKYO_MAGIC, 0, '<broadcast>', discovery_port)
34
+ data = []
35
+ loop do
36
+ msg, addr = sock.recvfrom_nonblock(1024)
37
+ data << Receiver.new(addr[2], ecn_string_to_ecn_array(msg))
38
+ rescue IO::WaitReadable
39
+ io = IO.select([sock], nil, nil, 0.5)
40
+ if io.nil?
41
+ return data
42
+ else
43
+ retry
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,26 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Gem::Specification.new do |s|
2
4
  s.name = 'onkyo_eiscp_ruby'
3
5
  s.version = File.read(File.expand_path('VERSION', File.dirname(__FILE__))).strip
6
+ s.licenses = ['MIT']
4
7
  s.platform = Gem::Platform::RUBY
5
8
  s.summary = 'Manipulate Onkyo stereos with the eISCP protocol'
6
9
  s.files = Dir.glob('{bin,config,lib,test,doc}/**/*') +
7
- ["VERSION", "onkyo_eiscp_ruby.gemspec", "eiscp-commands.yaml"]
8
- s.extra_rdoc_files = ["README.md"]
10
+ ['VERSION', 'onkyo_eiscp_ruby.gemspec', 'eiscp-commands.yaml']
11
+ s.extra_rdoc_files = ['README.md']
9
12
  s.require_path = 'lib'
10
13
 
11
- s.homepage = "https://github.com/mikerodrigues/onkyo_eiscp_ruby"
14
+ s.homepage = 'https://github.com/mikerodrigues/onkyo_eiscp_ruby'
12
15
 
13
- s.description = %q(
14
- Use the provided binary script or require the library for use in your scripts.
15
- )
16
+ s.description = '
17
+ Control Onkyo receivers over the network.Use the provided binary or
18
+ require the library for use in your scripts.
19
+ '
16
20
 
17
- s.author = "Michael Rodrigues"
18
- s.email = "mikebrodrigues@gmail.com"
21
+ s.author = 'Michael Rodrigues'
22
+ s.email = 'mikebrodrigues@gmail.com'
19
23
 
20
- s.test_files = Dir[ 'test/tc*.rb' ]
21
- s.executables = %w(
24
+ s.test_files = Dir['test/tc*.rb']
25
+ s.executables = %w[
22
26
  onkyo.rb
23
- onkyo-server.rb
24
- )
25
-
27
+ onkyo_server.rb
28
+ ]
26
29
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/eiscp/dictionary.rb'
4
+ require 'minitest/autorun'
5
+
6
+ class TestDictionary < MiniTest::Test
7
+ def test_zone_from_command
8
+ assert_equal(EISCP::Dictionary.zone_from_command('PWR'), 'main')
9
+ assert_equal(EISCP::Dictionary.zone_from_command('ZPW'), 'zone2')
10
+ assert_equal(EISCP::Dictionary.zone_from_command('CDS'), 'dock')
11
+ end
12
+
13
+ def test_command_to_name
14
+ assert_equal(EISCP::Dictionary.command_to_name('PWR'), 'system-power')
15
+ assert_equal(EISCP::Dictionary.command_to_name('ZPW'), 'power2')
16
+ assert_equal(EISCP::Dictionary.command_to_name('PW3'), 'power3')
17
+ assert_equal(EISCP::Dictionary.command_to_name('PW4'), 'power4')
18
+ end
19
+
20
+ def test_command_name_to_command
21
+ assert_equal(EISCP::Dictionary.command_name_to_command('system-power'), 'PWR')
22
+ assert_equal(EISCP::Dictionary.command_name_to_command('master-volume'), 'MVL')
23
+ assert_equal(EISCP::Dictionary.command_name_to_command('power2'), 'ZPW')
24
+ end
25
+
26
+ def test_command_value_to_value_name
27
+ assert_equal(EISCP::Dictionary.command_value_to_value_name('PWR', '01'), 'on')
28
+ assert_equal(EISCP::Dictionary.command_value_to_value_name('PWR', 'QSTN'), 'query')
29
+ end
30
+
31
+ def test_command_value_name_to_value
32
+ assert_equal(EISCP::Dictionary.command_value_name_to_value('PWR', 'on'), '01')
33
+ assert_equal(EISCP::Dictionary.command_value_name_to_value('ZPW', 'on'), '01')
34
+ end
35
+
36
+ def test_description_from_command_name
37
+ assert_equal(EISCP::Dictionary.description_from_command_name('system-power', 'main'), 'System Power Command')
38
+ assert_equal(EISCP::Dictionary.description_from_command_name('power2', 'zone2'), 'Zone2 Power Command')
39
+ end
40
+
41
+ def test_description_from_command
42
+ assert_equal(EISCP::Dictionary.description_from_command('PWR'), 'System Power Command')
43
+ assert_equal(EISCP::Dictionary.description_from_command('ZPW'), 'Zone2 Power Command')
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/eiscp/message'
4
+ require 'minitest/autorun'
5
+
6
+ class TestMessage < MiniTest::Test
7
+ DISCOVERY_PACKET = EISCP::Message.new(command: 'ECN', value: 'QSTN', terminator: "\r\n", unit_type: 'x', start: '!')
8
+ DISCOVERY_STRING = DISCOVERY_PACKET.to_eiscp
9
+
10
+ def test_create_discovery_iscp_message
11
+ assert_equal(EISCP::Message.new(command: 'ECN', value: 'QSTN', terminator: "\r\n", unit_type: 'x', start: '!').to_iscp, '!xECNQSTN')
12
+ end
13
+
14
+ def test_create_messages
15
+ assert_equal(EISCP::Message.new(command: 'PWR', value: '01').to_iscp, '!1PWR01')
16
+ assert_equal(EISCP::Message.new(command: 'MVL', value: 'QSTN').to_iscp, '!1MVLQSTN')
17
+ end
18
+
19
+ def test_create_discovery_packet_string
20
+ assert_equal(DISCOVERY_PACKET.to_eiscp, DISCOVERY_STRING)
21
+ end
22
+
23
+ def test_validate_valid_message_with_variable
24
+ # Commands that return something unexpected like an artist name
25
+ end
26
+ end