yamaha 0.0.2

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 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: []