yamaha 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f14111abebd3540efa54a52c6f3ceef20b5217879c6df1aa57e1aa293fea037
4
+ data.tar.gz: e8142bbe221620dc2d0e86a831fb58d5a21ef4c3099a08d05e674271ecefbe29
5
+ SHA512:
6
+ metadata.gz: 3bb84d02c1209763e280e294b5049c89b77e7518d11a131fc641acf0a030cc20dac6334db079064cb183a159a7d47b611b6638ac39f4056b4a902dd8dda6d79f
7
+ data.tar.gz: d677cba18bbd2c20f2d89c7902a5f1f82669635038709d3e551ea07e2d6f2f435856a68932c36c1603e3ee49a1b1dd5d3d6bc2d7c18b723eead3c58ad3b6b13d
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2022 Oleg Pudeyev
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Yamaha Receiver Serial Control Ruby Library
2
+
3
+ ## Protocol Notes
4
+
5
+ ### RX-V1500 Power Values
6
+
7
+ You might expect the power state to be a bit field, but it isn't - each
8
+ combination appears to have been assigned an independent value:
9
+
10
+ | Main zone | Zone 2 | Zone 3 | Value | Notes
11
+ | On | On | On | 1 | All on
12
+ | On | On | Off | 4 |
13
+ | On | Off | On | 5 |
14
+ | On | Off | Off | 2 |
15
+ | Off | On | On | 3 |
16
+ | Off | On | Off | 6 |
17
+ | Off | Off | On | 7 |
18
+ | Off | Off | Off | 0 | All off
19
+
20
+ ## Implementation Notes
21
+
22
+ In order for the receiver to respond, the RTS bit must be set on the wire.
23
+ Setting this bit requires a 5-wire cable. I have some RS232 to 3.5 mm cables
24
+ which aren't usable with Yamahas.
25
+
26
+ Linux appears to automatically set the RTS bit upon opening the serial port,
27
+ thus setting it explicitly may not be needed.
28
+
29
+ To monitor serial communications under Linux, I used
30
+ [slsnif](https://github.com/aeruder/slsnif) which I found via
31
+ [this summary of serial port monitoring tools](https://serverfault.com/questions/112957/sniff-serial-port-on-linux).
32
+
33
+ The receiver is very frequently not responding to the "ready" command.
34
+ The documentation mentions retrying this command but in my experience the
35
+ first time this command is sent to a RX-V1500 which is in standby it is
36
+ *always* igored.
37
+
38
+ I have RX-V1500 and RX-V2500, however I couldn't locate RS232 protocol manuals
39
+ for these receivers. I am primarily using RX-V1700/RX-V2700 manual with some
40
+ references to RX-V1000/RX-V3000 manual. The commands are mostly or completely
41
+ identical, with RX-V1700/RX-V2700 manual describing most or all of what
42
+ RX-V1500/RX-V2500 support, but the status responses are very different.
43
+ For my RX-V1500/RX-V2500 I had to reverse-engineer the status responses, and
44
+ because of this they only have a limited number of fields decoded.
45
+
46
+ Volume level is set and reported as follows: 0 means muting is active,
47
+ otherwise the minimum level for the zone is 39 and each step in the level is
48
+ the next integer value up. For the main zone on RX-V1500/RX-V2500, the volume
49
+ is adjusted in 0.5 dB increments from -80 dB to 14.5 dB, giving the integer
50
+ values the range of 39-228. For zones 2 and 3 the volume is adjusted in whole
51
+ dB increments from -33 dB to 0 dB, giving the integer range of 39-72.
52
+
53
+ While testing with Python, I ran into [this issue](https://bugs.python.org/issue20074) -
54
+ to open a TTY in Python, buffering must be disabled.
55
+
56
+ See [here](https://www.avsforum.com/threads/enhancing-yamaha-avrs-via-rs-232.1066484/)
57
+ for more Yamaha-related software.
58
+
59
+ ## Other Libraries
60
+
61
+ Yamaha RS232/serial protocol:
62
+
63
+ - [YRXV1500-MQTT](https://github.com/FireFrei/yrxv1500-mqtt)
64
+ - [YamahaController](https://github.com/mrworf/yamahacontroller)
65
+ - [Homie ESP8266 Yamaha RX-Vxxxx Control]https://github.com/memphi2/homie-yamaha-rs232)
66
+
67
+ Serial port communication in Ruby:
68
+
69
+ - [rubyserial](https://github.com/hybridgroup/rubyserial)
70
+ - [Ruby/SerialPort](https://github.com/hparra/ruby-serialport)
71
+
72
+ ## Helpful Links
73
+
74
+ - [Serial port programming in Ruby](https://www.thegeekdiary.com/serial-port-programming-reading-writing-status-of-control-lines-dtr-rts-cts-dsr/)
data/bin/yamaha ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ begin
6
+ require 'yamaha'
7
+ rescue LoadError
8
+ $: << File.join(File.dirname(__FILE__), '../lib')
9
+ require 'yamaha'
10
+ end
11
+ require 'optparse'
12
+ require 'logger'
13
+ require 'pp'
14
+
15
+ options = {}
16
+ OptionParser.new do |opts|
17
+ opts.banner = "Usage: yamaha [-d device] command arg..."
18
+
19
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
20
+ options[:device] = v
21
+ end
22
+ end.parse!
23
+
24
+ logger = Logger.new(STDERR)
25
+ client = Yamaha::Client.new(options[:device], logger: logger)
26
+
27
+ cmd = ARGV.shift
28
+ unless cmd
29
+ raise ArgumentError, "No command given"
30
+ end
31
+
32
+ def parse_on_off(value)
33
+ case value&.downcase
34
+ when '1', 'on', 'yes', 'true'
35
+ true
36
+ when '0', 'off', 'no', 'value'
37
+ false
38
+ else
39
+ raise ArgumentError, "Invalid on/off value: #{value}"
40
+ end
41
+ end
42
+
43
+ case cmd
44
+ when 'detect'
45
+ device = Yamaha::Client.detect_device(*ARGV, logger: logger)
46
+ if device
47
+ puts device
48
+ exit 0
49
+ else
50
+ STDERR.puts("Yamaha receiver not found")
51
+ exit 3
52
+ end
53
+ when 'power'
54
+ which = ARGV.shift&.downcase
55
+ if %w(main zone2 zone3).include?(which)
56
+ method = "set_#{which}_power"
57
+ state = parse_on_off(ARGV.shift)
58
+ else
59
+ method = 'set_power'
60
+ state = parse_on_off(which)
61
+ end
62
+ client.public_send(method, state)
63
+ when 'volume'
64
+ which = ARGV.shift
65
+ if %w(main zone2 zone3).include?(which)
66
+ prefix = "set_#{which}"
67
+ value = ARGV.shift
68
+ else
69
+ prefix = 'set_main'
70
+ value = which
71
+ end
72
+ if %w(. -).include?(value)
73
+ method = "#{prefix}_mute"
74
+ value = true
75
+ else
76
+ method = "#{prefix}_volume_db"
77
+ if value[0] == ','
78
+ value = value[1..]
79
+ end
80
+ value = Float(value)
81
+ end
82
+ client.public_send(method, value)
83
+ p client.get_main_volume_text
84
+ p client.get_zone2_volume_text
85
+ p client.get_zone3_volume_text
86
+ when 'input'
87
+ which = ARGV.shift&.downcase
88
+ if %w(main zone2 zone3).include?(which)
89
+ method = "set_#{which}_input"
90
+ input = ARGV.shift
91
+ else
92
+ method = 'set_main_input'
93
+ input = which
94
+ end
95
+ client.public_send(method, input)
96
+ when 'program'
97
+ value = ARGV.shift.downcase
98
+ client.set_program(value)
99
+ when 'pure-direct'
100
+ state = parse_on_off(ARGV.shift)
101
+ client.set_pure_direct(state)
102
+ when 'status'
103
+ pp client.last_status
104
+ when 'status_string'
105
+ puts client.last_status_string
106
+ when 'test'
107
+ client.set_power(false)
108
+ [true, false].each do |main_state|
109
+ [true, false].each do |zone2_state|
110
+ [true, false].each do |zone3_state|
111
+ client.set_main_power(main_state)
112
+ client.set_zone2_power(zone2_state)
113
+ client.set_zone3_power(zone3_state)
114
+ puts "#{main_state ?1:0} #{zone2_state ?1:0} #{zone3_state ?1:0} #{client.status[:power]}"
115
+ end
116
+ end
117
+ end
118
+ else
119
+ raise ArgumentError, "Unknown command: #{cmd}"
120
+ end
@@ -0,0 +1,107 @@
1
+ require 'forwardable'
2
+ require 'ffi'
3
+
4
+ module Yamaha
5
+ module Backend
6
+ module FFIBackend
7
+
8
+ class Device
9
+ extend Forwardable
10
+
11
+ def initialize(device, logger: nil)
12
+ @logger = logger
13
+
14
+ if @f
15
+ yield
16
+ else
17
+ logger&.debug("Opening device #{device}")
18
+ File.open(device, 'r+') do |f|
19
+ unless f.isatty
20
+ raise BadDevice, "#{device} is not a TTY"
21
+ end
22
+ @f = f
23
+ set_rts
24
+
25
+ if IO.select([f], nil, nil, 0)
26
+ logger&.warn("Serial device readable without having been written to - concurrent access?")
27
+ end
28
+
29
+ tries = 0
30
+ begin
31
+ do_status
32
+ rescue Timeout::Error
33
+ tries += 1
34
+ if tries < 5
35
+ logger&.warn("Timeout handshaking with the receiver - will retry")
36
+ retry
37
+ else
38
+ raise
39
+ end
40
+ end
41
+ yield.tap do
42
+ @f = nil
43
+ end
44
+ end
45
+ end
46
+ rescue IOError => e
47
+ if @f
48
+ logger&.warn("#{e.class}: #{e} while operating, closing the device")
49
+ @f.close
50
+ raise
51
+ end
52
+ end
53
+
54
+ attr_reader :logger
55
+ def_delegators :@f, :close
56
+
57
+ def set_rts
58
+ ptr = IntPtr.new
59
+ C.ioctl_p(@f.fileno, TIOCMGET, ptr)
60
+ if logger&.level <= Logger::DEBUG
61
+ flags = []
62
+ %w(DTR RTS CTS).each do |bit|
63
+ if ptr[:value] & self.class.const_get("TIOCM_#{bit}") > 0
64
+ flags << bit
65
+ end
66
+ end
67
+ if flags.empty?
68
+ flags = ['(none)']
69
+ end
70
+ logger&.debug("Initial flags: #{flags.join(' ')}")
71
+ end
72
+ unless ptr[:value] & TIOCM_RTS
73
+ logger&.debug("Setting RTS on #{device}")
74
+ ptr[:value] |= TIOCM_RTS
75
+ C.ioctl_p(@f.fileno, TIOCMSET, ptr)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ class IntPtr < FFI::Struct
82
+ layout :value, :int
83
+ end
84
+
85
+ module C
86
+ extend FFI::Library
87
+ ffi_lib 'c'
88
+
89
+ # Ruby's ioctl doesn't support all of C ioctl interface,
90
+ # in particular returning integer values that we need.
91
+ # See https://stackoverflow.com/questions/1446806/getting-essid-via-ioctl-in-ruby.
92
+ attach_function :ioctl, [:int, :int, :pointer], :int
93
+ class << self
94
+ alias :ioctl_p :ioctl
95
+ end
96
+ remove_method :ioctl
97
+ end
98
+
99
+ TIOCMGET = 0x5415
100
+ TIOCMSET = 0x5418
101
+ TIOCM_DTR = 0x002
102
+ TIOCM_RTS = 0x004
103
+ TIOCM_CTS = 0x020
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,24 @@
1
+ require 'forwardable'
2
+ require 'serialport'
3
+
4
+ module Yamaha
5
+ module Backend
6
+ module SerialPortBackend
7
+
8
+ class Device
9
+ extend Forwardable
10
+
11
+ def initialize(device, logger: nil)
12
+ @logger = logger
13
+ @io = SerialPort.open(device)
14
+ end
15
+
16
+ attr_reader :device
17
+
18
+ attr_reader :io
19
+
20
+ def_delegators :io, :close, :sysread, :syswrite
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'yamaha/backend/serial_port'
5
+
6
+ module Yamaha
7
+
8
+ class Error < StandardError; end
9
+ class BadDevice < Error; end
10
+ class BadStatus < Error; end
11
+ class InvalidCommand < Error; end
12
+ class NotApplicable < Error; end
13
+ class UnexpectedResponse < Error; end
14
+ class CommunicationTimeout < Error; end
15
+
16
+ RS232_TIMEOUT = 9
17
+ DEFAULT_DEVICE_GLOB = '/dev/ttyUSB*'
18
+
19
+ class Client
20
+ def self.detect_device(*patterns, logger: nil)
21
+ if patterns.empty?
22
+ patterns = [DEFAULT_DEVICE_GLOB]
23
+ end
24
+ devices = patterns.map do |pattern|
25
+ Dir.glob(pattern)
26
+ end.flatten.uniq
27
+ found = nil
28
+ threads = devices.map do |device|
29
+ Thread.new do
30
+ Timeout.timeout(RS232_TIMEOUT) do
31
+ logger&.debug("Trying #{device}")
32
+ new(device, logger: logger).status
33
+ logger&.debug("Found receiver at #{device}")
34
+ found = device
35
+ end
36
+ end
37
+ end
38
+ threads.map(&:join)
39
+ found
40
+ end
41
+
42
+ def initialize(device = nil, logger: nil)
43
+ @logger = logger
44
+
45
+ if device.nil?
46
+ device = Dir[DEFAULT_DEVICE_GLOB].sort.first
47
+ if device
48
+ logger&.info("Using #{device} as TTY device")
49
+ end
50
+ end
51
+
52
+ unless device
53
+ raise ArgumentError, "No device specified and device could not be detected automatically"
54
+ end
55
+
56
+ @device = device
57
+
58
+ if block_given?
59
+ open_device
60
+ begin
61
+ yield self
62
+ ensure
63
+ close
64
+ end
65
+ end
66
+ end
67
+
68
+ attr_reader :device
69
+ attr_accessor :logger
70
+
71
+ def last_status
72
+ unless @status
73
+ open_device do
74
+ end
75
+ end
76
+ @status.dup
77
+ end
78
+
79
+ def last_status_string
80
+ unless @status_string
81
+ open_device do
82
+ end
83
+ end
84
+ @status_string.dup
85
+ end
86
+
87
+ def status
88
+ do_status
89
+ last_status
90
+ end
91
+
92
+ def set_power(state)
93
+ remote_command("7A1#{state ? 'D' : 'E'}")
94
+ end
95
+
96
+ def set_main_power(state)
97
+ remote_command("7E7#{state ? 'E' : 'F'}")
98
+ end
99
+
100
+ def set_zone2_power(state)
101
+ remote_command("7EB#{state ? 'A' : 'B'}")
102
+ end
103
+
104
+ def set_zone3_power(state)
105
+ remote_command("7AE#{state ? 'D' : 'E'}")
106
+ end
107
+
108
+ def set_main_volume(value)
109
+ system_command("30#{'%02x' % value}")
110
+ end
111
+
112
+ def set_main_volume_db(volume)
113
+ value = Integer((volume + 80) * 2 + 39)
114
+ set_main_volume(value)
115
+ end
116
+
117
+ def set_zone2_volume(value)
118
+ system_command("31#{'%02x' % value}")
119
+ end
120
+
121
+ def set_zone2_volume_db(volume)
122
+ value = Integer(volume + 33 + 39)
123
+ set_zone2_volume(value)
124
+ end
125
+
126
+ def zone2_volume_up
127
+ remote_command('7ADA')
128
+ end
129
+
130
+ def zone2_volume_down
131
+ remote_command('7ADB')
132
+ end
133
+
134
+ def set_zone3_volume(volume)
135
+ remote_command("234#{'%02x' % value}")
136
+ end
137
+
138
+ def zone3_volume_up
139
+ remote_command('7AFD')
140
+ end
141
+
142
+ def zone3_volume_down
143
+ remote_command('7AFE')
144
+ end
145
+
146
+ def set_subwoofer_level(level)
147
+ dispatch("#{STX}249#{'%02x' % level}#{ETX}")
148
+ end
149
+
150
+ def get_main_volume_text
151
+ extract_text(system_command("2001"))[3...].strip
152
+ end
153
+
154
+ def get_zone2_volume_text
155
+ extract_text(system_command("2002"))[3...].strip
156
+ end
157
+
158
+ def get_zone3_volume_text
159
+ extract_text(system_command("2005"))[3...].strip
160
+ end
161
+
162
+ def set_pure_direct(state)
163
+ dispatch("#{STX}07E8#{state ? '0' : '2'}#{ETX}")
164
+ end
165
+
166
+ PROGRAM_SET = {
167
+ 'munich' => 'E1',
168
+ 'vienna' => 'E5',
169
+ 'amsterdam' => 'E6',
170
+ 'freiburg' => 'E8',
171
+ 'chamber' => 'AF',
172
+ 'village_vanguard' => 'EB',
173
+ 'warehouse_loft' => 'EE',
174
+ 'cellar_club' => 'CD',
175
+ 'the_bottom_line' => 'EC',
176
+ 'the_roxy_theatre' => 'ED',
177
+ 'disco' => 'F0',
178
+ 'game' => 'F2',
179
+ '7ch_stereo' => 'FF',
180
+ '2ch_stereo' => 'C0',
181
+ 'sports' => 'F8',
182
+ 'action_game' => 'F2',
183
+ 'roleplaying_game' => 'CE',
184
+ 'music_video' => 'F3',
185
+ 'recital_opera' => 'F5',
186
+ 'standard' => 'FE',
187
+ 'spectacle' => 'F9',
188
+ 'sci-fi' => 'FA',
189
+ 'adventure' => 'FB',
190
+ 'drama' => 'FC',
191
+ 'mono_movie' => 'F7',
192
+ 'surround_decode' => 'FD',
193
+ 'thx_cinema' => 'C2',
194
+ 'thx_music' => 'C3',
195
+ 'thx_game' => 'C8',
196
+ }.freeze
197
+
198
+ def set_program(value)
199
+ program_code = PROGRAM_SET.fetch(value.downcase.gsub(/[^a-z]/, '_'))
200
+ remote_command("7E#{program_code}")
201
+ end
202
+
203
+ MAIN_INPUTS_SET = {
204
+ 'phono' => '14',
205
+ 'cd' => '15',
206
+ 'tuner' => '16',
207
+ 'cd_r' => '19',
208
+ 'md_tape' => '18',
209
+ 'dvd' => 'C1',
210
+ 'dtv' => '54',
211
+ 'cbl_sat' => 'C0',
212
+ 'vcr1' => '0F',
213
+ 'dvr_vcr2' => '13',
214
+ 'v_aux_dock' => '55',
215
+ 'multi_ch' => '87',
216
+ 'xm' => 'B4',
217
+ }.freeze
218
+
219
+ def set_main_input(source)
220
+ source_code = MAIN_INPUTS_SET.fetch(source.downcase.gsub(/[^a-z]/, '_'))
221
+ remote_command("7A#{source_code}")
222
+ end
223
+
224
+ ZONE2_INPUTS_SET = {
225
+ 'phono' => 'D0',
226
+ 'cd' => 'D1',
227
+ 'tuner' => 'D2',
228
+ 'cd_r' => 'D4',
229
+ 'md_tape' => 'D3',
230
+ 'dvd' => 'CD',
231
+ 'dtv' => 'D9',
232
+ 'cbl_sat' => 'CC',
233
+ 'vcr1' => 'D6',
234
+ 'dvr_vcr2' => 'D7',
235
+ 'v_aux_dock' => 'D8',
236
+ 'xm' => 'B8',
237
+ }.freeze
238
+
239
+ def set_zone2_input(source)
240
+ source_code = ZONE2_INPUTS_SET.fetch(source.downcase.gsub(/[^a-z]/, '_'))
241
+ remote_command("7A#{source_code}")
242
+ end
243
+
244
+ ZONE3_INPUTS_SET = {
245
+ 'phono' => 'F1',
246
+ 'cd' => 'F2',
247
+ 'tuner' => 'F3',
248
+ 'cd_r' => 'F5',
249
+ 'md_tape' => 'F4',
250
+ 'dvd' => 'FC',
251
+ 'dtv' => 'F6',
252
+ 'cbl_sat' => 'F7',
253
+ 'vcr1' => 'F9',
254
+ 'dvr_vcr2' => 'FA',
255
+ 'v_aux_dock' => 'F0',
256
+ 'xm' => 'B9',
257
+ }.freeze
258
+
259
+ def set_zone3_input(source)
260
+ source_code = ZONE3_INPUTS_SET.fetch(source.downcase.gsub(/[^a-z]/, '_'))
261
+ remote_command("7A#{source_code}")
262
+ end
263
+
264
+ private
265
+
266
+ def open_device
267
+ @f = Backend::SerialPortBackend::Device.new(device, logger: logger)
268
+
269
+ tries = 0
270
+ begin
271
+ do_status
272
+ rescue CommunicationTimeout
273
+ tries += 1
274
+ if tries < 5
275
+ logger&.warn("Timeout handshaking with the receiver - will retry")
276
+ retry
277
+ else
278
+ raise
279
+ end
280
+ end
281
+
282
+ yield.tap do
283
+ @f.close
284
+ end
285
+ end
286
+
287
+ # ASCII table: https://www.asciitable.com/
288
+ DC1 = +?\x11
289
+ DC2 = +?\x12
290
+ ETX = +?\x03
291
+ STX = +?\x02
292
+ DEL = +?\x7f
293
+
294
+ STATUS_REQ = -"#{DC1}001#{ETX}"
295
+
296
+ ZERO_ORD = '0'.ord
297
+
298
+ def dispatch(cmd)
299
+ open_device do
300
+ do_dispatch(cmd)
301
+ end
302
+ end
303
+
304
+ def do_dispatch(cmd)
305
+ @f.syswrite(cmd.encode('ascii'))
306
+ read_response
307
+ end
308
+
309
+ def read_response
310
+ resp = +''
311
+ Timeout.timeout(2, CommunicationTimeout) do
312
+ loop do
313
+ ch = @f.sysread(1)
314
+ if ch
315
+ resp << ch
316
+ break if ch == ETX
317
+ else
318
+ sleep 0.1
319
+ end
320
+ end
321
+ end
322
+ resp
323
+ end
324
+
325
+ MAIN_INPUTS_GET = {
326
+ '0' => 'PHONO',
327
+ '1' => 'CD',
328
+ '2' => 'TUNER',
329
+ '3' => 'CD-R',
330
+ '4' => 'MD/TAPE',
331
+ '5' => 'DVD',
332
+ '6' => 'DTV',
333
+ '7' => 'CBL/SAT',
334
+ '8' => 'SAT',
335
+ '9' => 'VCR1',
336
+ 'A' => 'DVR/VCR2',
337
+ 'B' => 'VCR3/DVR',
338
+ 'C' => 'V-AUX/DOCK',
339
+ 'D' => 'NET/USB',
340
+ 'E' => 'XM',
341
+ }.freeze
342
+
343
+ AUDIO_SELECT_GET = {
344
+ '0' => 'Auto', # Confirmed RX-V1500
345
+ '2' => 'DTS', # Confirmed RX-V1500
346
+ '3' => 'Coax / Opt', # Unconfirmed
347
+ '4' => 'Analog', # Confirmed RX-V1500
348
+ '5' => 'Analog Only', # Unconfirmed
349
+ '8' => 'HDMI', # Unconfirmed
350
+ }.freeze
351
+
352
+ NIGHT_GET = {
353
+ '0' => 'Off',
354
+ '1' => 'Cinema',
355
+ '2' => 'Music',
356
+ }.freeze
357
+
358
+ SLEEP_GET = {
359
+ '0' => 120,
360
+ '1' => 90,
361
+ '2' => 60,
362
+ '3' => 30,
363
+ '4' => nil,
364
+ }.freeze
365
+
366
+ PROGRAM_GET = {
367
+ '00' => 'Munich',
368
+ '01' => 'Hall B',
369
+ '02' => 'Hall C',
370
+ '04' => 'Hall D',
371
+ '05' => 'Vienna',
372
+ '06' => 'Live Concert',
373
+ '07' => 'Hall in Amsterdam',
374
+ '08' => 'Tokyo',
375
+ '09' => 'Freiburg',
376
+ '0A' => 'Royaumont',
377
+ '0B' => 'Chamber',
378
+ '0C' => 'Village Gate',
379
+ '0D' => 'Village Vanguard',
380
+ '0E' => 'The Bottom Line',
381
+ '0F' => 'Cellar Club',
382
+ '10' => 'The Roxy Theater',
383
+ '11' => 'Warehouse Loft',
384
+ '12' => 'Arena',
385
+ '14' => 'Disco',
386
+ '15' => 'Party',
387
+ '17' => '7ch Stereo',
388
+ '18' => 'Music Video',
389
+ '19' => 'DJ',
390
+ '1C' => 'Recital/Opera',
391
+ '1D' => 'Pavilion',
392
+ '1E' => 'Action Gamae',
393
+ '1F' => 'Role Playing Game',
394
+ '20' => 'Mono Movie',
395
+ '21' => 'Sports',
396
+ '24' => 'Spectacle',
397
+ '25' => 'Sci-Fi',
398
+ '28' => 'Adventure',
399
+ '29' => 'Drama',
400
+ '2C' => 'Surround Decode',
401
+ '2D' => 'Standard',
402
+ '30' => 'PLII Movie',
403
+ '31' => 'PLII Music',
404
+ '32' => 'Neo:6 Movie',
405
+ '33' => 'Neo:6 Music',
406
+ '34' => '2ch Stereo',
407
+ '35' => 'Direct Stereo',
408
+ '36' => 'THX Cinema',
409
+ '37' => 'THX Music',
410
+ '3C' => 'THX Game',
411
+ '40' => 'Enhancer 2ch Low',
412
+ '41' => 'Enhancer 2ch High',
413
+ '42' => 'Enhancer 7ch Low',
414
+ '43' => 'Enhancer 7ch Higgh',
415
+ '80' => 'Straight',
416
+ }.freeze
417
+
418
+ def do_status
419
+ resp = nil
420
+ loop do
421
+ resp = do_dispatch(STATUS_REQ)
422
+ again = false
423
+ while @f && IO.select([@f.io], nil, nil, 0)
424
+ logger&.warn("Serial device readable after completely reading status response - concurrent access?")
425
+ read_response
426
+ again = true
427
+ end
428
+ break unless again
429
+ end
430
+ payload = resp[1...-1]
431
+ @model_code = payload[0..4]
432
+ @version = payload[5]
433
+ length = payload[6..7].to_i(16)
434
+ p payload
435
+ data = payload[8...-2]
436
+ if data.length != length
437
+ raise BadStatus, "Broken status response: expected #{length} bytes, got #{data.length} bytes; concurrent operation on device?"
438
+ end
439
+ unless data.start_with?('@E01900')
440
+ raise BadStatus, "Broken status response: expected to start with @E01900, actual #{data[0..6]}"
441
+ end
442
+ puts data, data.length
443
+ p payload
444
+ @status_string = data
445
+ @status = {
446
+ # RX-V1500: model R0177
447
+ model_code: @model_code,
448
+ firmware_version: @version,
449
+ system_status: data[7].ord - ZERO_ORD,
450
+ power: power = data[8].ord - ZERO_ORD,
451
+ main_power: [1, 4, 5, 2].include?(power),
452
+ zone2_power: [1, 4, 3, 6].include?(power),
453
+ zone3_power: [1, 5, 3, 7].include?(power),
454
+ }
455
+ if data.length > 9
456
+ @status.update(
457
+ input: input = data[9],
458
+ input_name: MAIN_INPUTS_GET.fetch(input),
459
+ multi_ch_input: data[10] == '1',
460
+ audio_select: audio_select = data[11],
461
+ audio_select_name: AUDIO_SELECT_GET.fetch(audio_select),
462
+ mute: data[12] == '1',
463
+ # Volume values (0.5 dB increment):
464
+ # mute: 0
465
+ # -80.0 dB (min): 39
466
+ # 0 dB: 199
467
+ # +14.5 dB (max): 228
468
+ # Zone2 volume values (1 dB increment):
469
+ # mute: 0
470
+ # -33 dB (min): 39
471
+ # 0 dB (max): 72
472
+ main_volume: volume = data[15..16].to_i(16),
473
+ main_volume_db: int_to_half_db(volume),
474
+ zone2_volume: zone2_volume = data[17..18].to_i(16),
475
+ zone2_volume_db: int_to_full_db(zone2_volume),
476
+ zone3_volume: zone3_volume = data[129..130].to_i(16),
477
+ zone3_volume_db: int_to_full_db(zone3_volume),
478
+ program: program = data[19..20],
479
+ program_name: PROGRAM_GET.fetch(program),
480
+ # true: straight; false: effect
481
+ effect: data[21] == '1',
482
+ #extended_surround: data[22],
483
+ #short_message: data[23],
484
+ sleep: SLEEP_GET.fetch(data[24]),
485
+ night: night = data[27],
486
+ night_name: NIGHT_GET.fetch(night),
487
+ pure_direct: data[28] == '1',
488
+ speaker_a: data[29] == '1',
489
+ speaker_b: data[30] == '1',
490
+ format: data[31..32],
491
+ sample_rate: data[33..34],
492
+ )
493
+ end
494
+ @status
495
+ end
496
+
497
+ def remote_command(cmd)
498
+ dispatch("#{STX}0#{cmd}#{ETX}")
499
+ end
500
+
501
+ def system_command(cmd)
502
+ dispatch("#{STX}2#{cmd}#{ETX}")
503
+ end
504
+
505
+ def extract_text(resp)
506
+ # TODO: assert resp[0] == DC1, resp[-1] == ETX
507
+ resp[0...-1]
508
+ end
509
+
510
+ def int_to_half_db(value)
511
+ if value == 0
512
+ :mute
513
+ else
514
+ (value - 39) / 2.0 - 80
515
+ end
516
+ end
517
+
518
+ def int_to_full_db(value)
519
+ if value == 0
520
+ :mute
521
+ else
522
+ (value - 39) - 33
523
+ end
524
+ end
525
+ end
526
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamaha
4
+ VERSION = -'0.0.2'
5
+ end
data/lib/yamaha.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yamaha/version'
4
+ require 'yamaha/client'
data/yamaha.gemspec ADDED
@@ -0,0 +1,17 @@
1
+ # coding: utf-8
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "yamaha"
5
+ spec.version = '0.0.2'
6
+ spec.authors = ['Oleg Pudeyev']
7
+ spec.email = ['code@olegp.name']
8
+ spec.summary = %q{Yamaha Receiver Serial Control Interface}
9
+ spec.description = %q{Library for controlling Yamaha amplifiers via the serial port}
10
+ spec.homepage = "https://github.com/p/yamaha-ruby"
11
+ spec.license = "MIT"
12
+
13
+ spec.files = `git ls-files -z`.split("\x0")
14
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
15
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16
+ spec.require_paths = ["lib"]
17
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yamaha
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Oleg Pudeyev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-11-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Library for controlling Yamaha amplifiers via the serial port
14
+ email:
15
+ - code@olegp.name
16
+ executables:
17
+ - yamaha
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - bin/yamaha
24
+ - lib/yamaha.rb
25
+ - lib/yamaha/backend/ffi.rb
26
+ - lib/yamaha/backend/serial_port.rb
27
+ - lib/yamaha/client.rb
28
+ - lib/yamaha/version.rb
29
+ - yamaha.gemspec
30
+ homepage: https://github.com/p/yamaha-ruby
31
+ licenses:
32
+ - MIT
33
+ metadata: {}
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.3.15
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Yamaha Receiver Serial Control Interface
53
+ test_files: []