yamaha 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +74 -0
- data/bin/yamaha +120 -0
- data/lib/yamaha/backend/ffi.rb +107 -0
- data/lib/yamaha/backend/serial_port.rb +24 -0
- data/lib/yamaha/client.rb +526 -0
- data/lib/yamaha/version.rb +5 -0
- data/lib/yamaha.rb +4 -0
- data/yamaha.gemspec +17 -0
- metadata +53 -0
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
|
data/lib/yamaha.rb
ADDED
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: []
|