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.
- checksums.yaml +7 -0
- data/README.md +213 -29
- data/VERSION +1 -1
- data/bin/mock_receiver.rb +25 -0
- data/bin/onkyo.rb +85 -23
- data/bin/onkyo_server.rb +8 -0
- data/eiscp-commands.yaml +3911 -3900
- data/lib/eiscp.rb +9 -7
- data/lib/eiscp/dictionary.rb +54 -0
- data/lib/eiscp/dictionary/dictionary_generators.rb +63 -0
- data/lib/eiscp/dictionary/dictionary_helpers.rb +116 -0
- data/lib/eiscp/message.rb +123 -0
- data/lib/eiscp/parser.rb +24 -0
- data/lib/eiscp/parser/dynamic_value_parser.rb +9 -0
- data/lib/eiscp/parser/eiscp_parser.rb +37 -0
- data/lib/eiscp/parser/human_readable_parser.rb +29 -0
- data/lib/eiscp/parser/iscp_parser.rb +28 -0
- data/lib/eiscp/receiver.rb +203 -0
- data/lib/eiscp/receiver/command_methods.rb +28 -0
- data/lib/eiscp/receiver/discovery.rb +49 -0
- data/onkyo_eiscp_ruby.gemspec +16 -13
- data/test/tc_dictionary.rb +45 -0
- data/test/tc_message.rb +26 -0
- data/test/tc_parser.rb +34 -0
- data/test/tc_receiver.rb +7 -0
- metadata +37 -32
- data/bin/onkyo-server.rb +0 -7
- data/lib/eiscp/command.rb +0 -80
- data/lib/eiscp/eiscp.rb +0 -125
- data/lib/eiscp/eiscp_packet.rb +0 -51
- data/lib/eiscp/eiscp_server.rb +0 -21
- data/lib/eiscp/iscp_message.rb +0 -24
- data/test/tc_command.rb +0 -30
- data/test/tc_eiscp.rb +0 -6
- data/test/tc_eiscp_packet.rb +0 -17
- data/test/tc_iscp_message.rb +0 -14
@@ -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
|
data/onkyo_eiscp_ruby.gemspec
CHANGED
@@ -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
|
-
|
8
|
-
s.extra_rdoc_files = [
|
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 =
|
14
|
+
s.homepage = 'https://github.com/mikerodrigues/onkyo_eiscp_ruby'
|
12
15
|
|
13
|
-
s.description =
|
14
|
-
|
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 =
|
18
|
-
s.email =
|
21
|
+
s.author = 'Michael Rodrigues'
|
22
|
+
s.email = 'mikebrodrigues@gmail.com'
|
19
23
|
|
20
|
-
s.test_files = Dir[
|
21
|
-
s.executables = %w
|
24
|
+
s.test_files = Dir['test/tc*.rb']
|
25
|
+
s.executables = %w[
|
22
26
|
onkyo.rb
|
23
|
-
|
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
|
data/test/tc_message.rb
ADDED
@@ -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
|