seriamp 0.1.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: cd8d27d7818b801b6734b046fcc3682b1322bc03cfaa8d2472f4963d5974f7d5
4
+ data.tar.gz: 917a111090ce967b0c28684f60fc0ccf8dca627c0c5d6ae1699b97746be69afc
5
+ SHA512:
6
+ metadata.gz: 166e4cc102b91987bcb244aee644b682757609ad19ea42eafb88216f09295b87344a7906d925ff0479fe58b4db477691404bcfb7211d9bab7b29d29fe41555f2
7
+ data.tar.gz: 4e4aac13144356caeb92bf05da4e50181f1f83613e5ed1bb857fe91c1562d6d352b81c9e9eb051f6f8f50cc348f4bb61f0e8f04887880480dbc6e8c19be513de
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ .yardoc
2
+ *.gem
3
+ /doc
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,75 @@
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 is assigned an independent value:
9
+
10
+ | Main zone | Zone 2 | Zone 3 | Value | Notes |
11
+ | --------- | ------ | ------ | ----- | ------- |
12
+ | On | On | On | 1 | All on |
13
+ | On | On | Off | 4 | |
14
+ | On | Off | On | 5 | |
15
+ | On | Off | Off | 2 | |
16
+ | Off | On | On | 3 | |
17
+ | Off | On | Off | 6 | |
18
+ | Off | Off | On | 7 | |
19
+ | Off | Off | Off | 0 | All off |
20
+
21
+ ## Implementation Notes
22
+
23
+ In order for the receiver to respond, the RTS bit must be set on the wire.
24
+ Setting this bit requires a 5-wire cable. I have some RS232 to 3.5 mm cables
25
+ which aren't usable with Yamahas.
26
+
27
+ Linux appears to automatically set the RTS bit upon opening the serial port,
28
+ thus setting it explicitly may not be needed.
29
+
30
+ To monitor serial communications under Linux, I used
31
+ [slsnif](https://github.com/aeruder/slsnif) which I found via
32
+ [this summary of serial port monitoring tools](https://serverfault.com/questions/112957/sniff-serial-port-on-linux).
33
+
34
+ The receiver is very frequently not responding to the "ready" command.
35
+ The documentation mentions retrying this command but in my experience the
36
+ first time this command is sent to a RX-V1500 which is in standby it is
37
+ *always* igored.
38
+
39
+ I have RX-V1500 and RX-V2500, however I couldn't locate RS232 protocol manuals
40
+ for these receivers. I am primarily using RX-V1700/RX-V2700 manual with some
41
+ references to RX-V1000/RX-V3000 manual. The commands are mostly or completely
42
+ identical, with RX-V1700/RX-V2700 manual describing most or all of what
43
+ RX-V1500/RX-V2500 support, but the status responses are very different.
44
+ For my RX-V1500/RX-V2500 I had to reverse-engineer the status responses, and
45
+ because of this they only have a limited number of fields decoded.
46
+
47
+ Volume level is set and reported as follows: 0 means muting is active,
48
+ otherwise the minimum level for the zone is 39 and each step in the level is
49
+ the next integer value up. For the main zone on RX-V1500/RX-V2500, the volume
50
+ is adjusted in 0.5 dB increments from -80 dB to 14.5 dB, giving the integer
51
+ values the range of 39-228. For zones 2 and 3 the volume is adjusted in whole
52
+ dB increments from -33 dB to 0 dB, giving the integer range of 39-72.
53
+
54
+ While testing with Python, I ran into [this issue](https://bugs.python.org/issue20074) -
55
+ to open a TTY in Python, buffering must be disabled.
56
+
57
+ See [here](https://www.avsforum.com/threads/enhancing-yamaha-avrs-via-rs-232.1066484/)
58
+ for more Yamaha-related software.
59
+
60
+ ## Other Libraries
61
+
62
+ Yamaha RS232/serial protocol:
63
+
64
+ - [YRXV1500-MQTT](https://github.com/FireFrei/yrxv1500-mqtt)
65
+ - [YamahaController](https://github.com/mrworf/yamahacontroller)
66
+ - [Homie ESP8266 Yamaha RX-Vxxxx Control](https://github.com/memphi2/homie-yamaha-rs232)
67
+
68
+ Serial port communication in Ruby:
69
+
70
+ - [rubyserial](https://github.com/hybridgroup/rubyserial)
71
+ - [Ruby/SerialPort](https://github.com/hparra/ruby-serialport)
72
+
73
+ ## Helpful Links
74
+
75
+ - [Serial port programming in Ruby](https://www.thegeekdiary.com/serial-port-programming-reading-writing-status-of-control-lines-dtr-rts-cts-dsr/)
data/bin/sonamp ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'seriamp/sonamp/cmd'
5
+ rescue LoadError
6
+ $: << File.join(File.dirname(__FILE__), '../lib')
7
+ require 'seriamp/sonamp/cmd'
8
+ end
9
+
10
+ Seriamp::Sonamp::Cmd.new(ARGV).run
data/bin/sonamp-web ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'seriamp/sonamp/app'
5
+ rescue LoadError
6
+ $: << File.join(File.dirname(__FILE__), '../lib')
7
+ require 'seriamp/sonamp/app'
8
+ end
9
+ require 'optparse'
10
+ require 'logger'
11
+
12
+ options = {}
13
+ OptionParser.new do |opts|
14
+ opts.banner = "Usage: sonamp-web [-d device] [-- rackup-options...]"
15
+
16
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
17
+ options[:device] = v
18
+ end
19
+
20
+ opts.separator ''
21
+ opts.separator 'To see rackup options: sonamp-web -- -h'
22
+ end.parse!
23
+
24
+ logger = Logger.new(STDERR)
25
+
26
+ #Sonamp::App.set :device, options[:device]
27
+ #Sonamp::App.set :logger, logger
28
+ Seriamp::Sonamp::App.set :client, Sonamp::Client.new(options[:device], logger: logger)
29
+
30
+ options = Rack::Server::Options.new.parse!(ARGV)
31
+ Rack::Server.start(options.merge(app: Sonamp::App))
data/bin/yamaha ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ begin
6
+ require 'seriamp/yamaha/cmd'
7
+ rescue LoadError
8
+ $: << File.join(File.dirname(__FILE__), '../lib')
9
+ require 'seriamp/yamaha/cmd'
10
+ end
11
+
12
+ Seriamp::Yamaha::Cmd.new(ARGV).run
data/bin/yamaha-web ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'seriamp/yamaha/app'
5
+ rescue LoadError
6
+ $: << File.join(File.dirname(__FILE__), '../lib')
7
+ require 'seriamp/yamaha/app'
8
+ end
9
+ require 'optparse'
10
+ require 'logger'
11
+
12
+ options = {}
13
+ OptionParser.new do |opts|
14
+ opts.banner = "Usage: yamaha-web [-d device] [-- rackup-options...]"
15
+
16
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
17
+ options[:device] = v
18
+ end
19
+
20
+ opts.separator ''
21
+ opts.separator 'To see rackup options: yamaha-web -- -h'
22
+ end.parse!
23
+
24
+ logger = Logger.new(STDERR)
25
+
26
+ #Yamaha::App.set :device, options[:device]
27
+ #Yamaha::App.set :logger, logger
28
+ Seriamp::Yamaha::App.set :client, Yamaha::Client.new(options[:device], logger: logger)
29
+
30
+ options = Rack::Server::Options.new.parse!(ARGV)
31
+ Rack::Server.start(options.merge(app: Yamaha::App))
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'ffi'
5
+
6
+ module Seriamp
7
+ module Backend
8
+ module FFIBackend
9
+
10
+ class Device
11
+ extend Forwardable
12
+
13
+ def initialize(device, logger: nil)
14
+ @logger = logger
15
+
16
+ if @f
17
+ yield
18
+ else
19
+ logger&.debug("Opening device #{device}")
20
+ File.open(device, 'r+') do |f|
21
+ unless f.isatty
22
+ raise BadDevice, "#{device} is not a TTY"
23
+ end
24
+ @f = f
25
+ set_rts
26
+
27
+ if IO.select([f], nil, nil, 0)
28
+ logger&.warn("Serial device readable without having been written to - concurrent access?")
29
+ end
30
+
31
+ tries = 0
32
+ begin
33
+ do_status
34
+ rescue Timeout::Error
35
+ tries += 1
36
+ if tries < 5
37
+ logger&.warn("Timeout handshaking with the receiver - will retry")
38
+ retry
39
+ else
40
+ raise
41
+ end
42
+ end
43
+ yield.tap do
44
+ @f = nil
45
+ end
46
+ end
47
+ end
48
+ rescue IOError => e
49
+ if @f
50
+ logger&.warn("#{e.class}: #{e} while operating, closing the device")
51
+ @f.close
52
+ raise
53
+ end
54
+ end
55
+
56
+ attr_reader :logger
57
+ def_delegators :@f, :close
58
+
59
+ def set_rts
60
+ ptr = IntPtr.new
61
+ C.ioctl_p(@f.fileno, TIOCMGET, ptr)
62
+ if logger&.level <= Logger::DEBUG
63
+ flags = []
64
+ %w(DTR RTS CTS).each do |bit|
65
+ if ptr[:value] & self.class.const_get("TIOCM_#{bit}") > 0
66
+ flags << bit
67
+ end
68
+ end
69
+ if flags.empty?
70
+ flags = ['(none)']
71
+ end
72
+ logger&.debug("Initial flags: #{flags.join(' ')}")
73
+ end
74
+ unless ptr[:value] & TIOCM_RTS
75
+ logger&.debug("Setting RTS on #{device}")
76
+ ptr[:value] |= TIOCM_RTS
77
+ C.ioctl_p(@f.fileno, TIOCMSET, ptr)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ class IntPtr < FFI::Struct
84
+ layout :value, :int
85
+ end
86
+
87
+ module C
88
+ extend FFI::Library
89
+ ffi_lib 'c'
90
+
91
+ # Ruby's ioctl doesn't support all of C ioctl interface,
92
+ # in particular returning integer values that we need.
93
+ # See https://stackoverflow.com/questions/1446806/getting-essid-via-ioctl-in-ruby.
94
+ attach_function :ioctl, [:int, :int, :pointer], :int
95
+ class << self
96
+ alias :ioctl_p :ioctl
97
+ end
98
+ remove_method :ioctl
99
+ end
100
+
101
+ TIOCMGET = 0x5415
102
+ TIOCMSET = 0x5418
103
+ TIOCM_DTR = 0x002
104
+ TIOCM_RTS = 0x004
105
+ TIOCM_CTS = 0x020
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'serialport'
5
+
6
+ module Seriamp
7
+ module Backend
8
+ module SerialPortBackend
9
+
10
+ class Device
11
+ extend Forwardable
12
+
13
+ def initialize(device, logger: nil)
14
+ @logger = logger
15
+ @io = SerialPort.open(device)
16
+
17
+ if block_given?
18
+ begin
19
+ yield self
20
+ ensure
21
+ close rescue nil
22
+ end
23
+ end
24
+ end
25
+
26
+ attr_reader :device
27
+
28
+ attr_reader :io
29
+
30
+ def_delegators :io, :close, :sysread, :syswrite
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'seriamp/error'
5
+
6
+ module Seriamp
7
+
8
+ DEFAULT_DEVICE_GLOB = '/dev/ttyUSB*'
9
+
10
+ module_function def detect_device(mod, *patterns, logger: nil)
11
+ if patterns.empty?
12
+ patterns = [DEFAULT_DEVICE_GLOB]
13
+ end
14
+ devices = patterns.map do |pattern|
15
+ Dir.glob(pattern)
16
+ end.flatten.uniq
17
+ queue = Queue.new
18
+ timeout = mod.const_get(:RS232_TIMEOUT)
19
+ client_cls = mod.const_get(:Client)
20
+ threads = devices.map do |device|
21
+ Thread.new do
22
+ Timeout.timeout(timeout * 2, CommunicationTimeout) do
23
+ logger&.debug("Trying #{device}")
24
+ client_cls.new(device: device, logger: logger).present?
25
+ logger&.debug("Found #{mod} device at #{device}")
26
+ queue << device
27
+ end
28
+ rescue CommunicationTimeout, IOError, SystemCallError => exc
29
+ logger&.debug("Failed on #{mod} #{device}: #{exc.class}: #{exc}")
30
+ end
31
+ end
32
+ wait_thread = Thread.new do
33
+ threads.each do |thread|
34
+ # Unhandled exceptions raised in threads are reraised by the join method
35
+ thread.join rescue nil
36
+ end
37
+ queue << nil
38
+ end
39
+ queue.shift.tap do
40
+ threads.map(&:kill)
41
+ wait_thread.kill
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,10 @@
1
+ module Seriamp
2
+ class Error < StandardError; end
3
+ class NoDevice < Error; end
4
+ class BadDevice < Error; end
5
+ class InvalidCommand < Error; end
6
+ class NotApplicable < Error; end
7
+ class UnexpectedResponse < Error; end
8
+ class HandshakeFailure < UnexpectedResponse; end
9
+ class CommunicationTimeout < Error; end
10
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'seriamp/utils'
5
+ require 'seriamp/sonamp/client'
6
+
7
+ module Seriamp
8
+ module Sonamp
9
+ class App < Sinatra::Base
10
+
11
+ set :device, nil
12
+ set :logger, nil
13
+ set :client, nil
14
+
15
+ get '/power' do
16
+ render_json(client.get_zone_power)
17
+ end
18
+
19
+ get '/zone/:zone/power' do |zone|
20
+ render_json(client.get_zone_power(zone.to_i))
21
+ end
22
+
23
+ put '/zone/:zone/power' do |zone|
24
+ state = Utils.parse_on_off(request.body.read)
25
+ client.set_zone_power(zone.to_i, state)
26
+ end
27
+
28
+ get '/zone/:zone/volume' do |zone|
29
+ render_json(client.get_zone_volume(zone.to_i))
30
+ end
31
+
32
+ put '/zone/:zone/volume' do |zone|
33
+ volume = request.body.read.to_i
34
+ client.set_zone_volume(zone.to_i, volume)
35
+ end
36
+
37
+ get '/channel/:channel/volume' do |channel|
38
+ render_json(client.get_channel_volume(channel.to_i))
39
+ end
40
+
41
+ put '/channel/:channel/volume' do |channel|
42
+ volume = request.body.read.to_i
43
+ client.set_channel_volume(channel.to_i, volume)
44
+ end
45
+
46
+ post '/' do
47
+ end
48
+
49
+ private
50
+
51
+ def client
52
+ settings.client || begin
53
+ @client ||= Sonamp::Client.new(settings.device, logger: settings.logger)
54
+ end
55
+ end
56
+
57
+ def render_json(data)
58
+ data.to_json
59
+ end
60
+ end
61
+ end
62
+ end