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