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.
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'seriamp/backend/serial_port'
5
+ require 'seriamp/yamaha/protocol/constants'
6
+ require 'seriamp/yamaha/protocol/methods'
7
+
8
+ module Seriamp
9
+ module Yamaha
10
+
11
+ RS232_TIMEOUT = 3
12
+
13
+ class Client
14
+ include Protocol::Methods
15
+
16
+ def initialize(device: nil, glob: nil, logger: nil)
17
+ @logger = logger
18
+
19
+ @device = device
20
+ @detect_device = device.nil?
21
+ @glob = glob
22
+
23
+ if block_given?
24
+ begin
25
+ yield self
26
+ ensure
27
+ close
28
+ end
29
+ end
30
+ end
31
+
32
+ attr_reader :device
33
+ attr_reader :glob
34
+ attr_reader :logger
35
+
36
+ def detect_device?
37
+ @detect_device
38
+ end
39
+
40
+ def present?
41
+ last_status
42
+ true
43
+ end
44
+
45
+ def last_status
46
+ unless @status
47
+ with_device do
48
+ end
49
+ end
50
+ @status.dup
51
+ end
52
+
53
+ def last_status_string
54
+ unless @status_string
55
+ with_device do
56
+ end
57
+ end
58
+ @status_string.dup
59
+ end
60
+
61
+ def status
62
+ do_status
63
+ last_status
64
+ end
65
+
66
+ %i(
67
+ model_code firmware_version system_status power main_power zone2_power
68
+ zone3_power input input_name audio_select audio_select_name
69
+ main_volume main_volume_db zone2_volume zone2_volume_db
70
+ zone3_volume zone3_volume_db program program_name sleep night night_name
71
+ format sample_rate
72
+ ).each do |meth|
73
+ define_method(meth) do
74
+ status.fetch(meth)
75
+ end
76
+
77
+ define_method("last_#{meth}") do
78
+ last_status.fetch(meth)
79
+ end
80
+ end
81
+
82
+ %i(
83
+ multi_ch_input effect pure_direct speaker_a speaker_b
84
+ ).each do |meth|
85
+ define_method("#{meth}?") do
86
+ status.fetch(meth)
87
+ end
88
+
89
+ define_method("last_#{meth}?") do
90
+ last_status.fetch(meth)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ include Protocol::Constants
97
+
98
+ def open_device
99
+ if detect_device? && device.nil?
100
+ @device = Seriamp.detect_device(Yamaha, *glob, logger: logger)
101
+ if @device
102
+ logger&.info("Using #{device} as TTY device")
103
+ else
104
+ raise NoDevice, "No device specified and device could not be detected automatically"
105
+ end
106
+ end
107
+
108
+ @io = Backend::SerialPortBackend::Device.new(device, logger: logger)
109
+
110
+ begin
111
+ tries = 0
112
+ begin
113
+ do_status
114
+ rescue CommunicationTimeout
115
+ tries += 1
116
+ if tries < 5
117
+ logger&.warn("Timeout handshaking with the receiver - will retry")
118
+ retry
119
+ else
120
+ raise
121
+ end
122
+ end
123
+
124
+ yield @io
125
+ ensure
126
+ @io.close rescue nil
127
+ @io = nil
128
+ end
129
+ end
130
+
131
+ def with_device(&block)
132
+ if @io
133
+ yield @io
134
+ else
135
+ open_device(&block)
136
+ end
137
+ end
138
+
139
+ # ASCII table: https://www.asciitable.com/
140
+ DC1 = ?\x11
141
+ DC2 = ?\x12
142
+ ETX = ?\x03
143
+ STX = ?\x02
144
+ DEL = ?\x7f
145
+
146
+ STATUS_REQ = "#{DC1}001#{ETX}"
147
+
148
+ ZERO_ORD = '0'.ord
149
+
150
+ def dispatch(cmd)
151
+ with_device do
152
+ @io.syswrite(cmd.encode('ascii'))
153
+ read_response
154
+ end
155
+ end
156
+
157
+ def read_response
158
+ resp = +''
159
+ Timeout.timeout(2, CommunicationTimeout) do
160
+ loop do
161
+ ch = @io.sysread(1)
162
+ if ch
163
+ resp << ch
164
+ break if ch == ETX
165
+ else
166
+ sleep 0.1
167
+ end
168
+ end
169
+ end
170
+ resp
171
+ end
172
+
173
+ def do_status
174
+ resp = nil
175
+ loop do
176
+ resp = dispatch(STATUS_REQ)
177
+ again = false
178
+ while @io && IO.select([@io.io], nil, nil, 0)
179
+ logger&.warn("Serial device readable after completely reading status response - concurrent access?")
180
+ read_response
181
+ again = true
182
+ end
183
+ break unless again
184
+ end
185
+ payload = resp[1...-1]
186
+ @model_code = payload[0..4]
187
+ @version = payload[5]
188
+ length = payload[6..7].to_i(16)
189
+ data = payload[8...-2]
190
+ if data.length != length
191
+ raise BadStatus, "Broken status response: expected #{length} bytes, got #{data.length} bytes; concurrent operation on device?"
192
+ end
193
+ unless data.start_with?('@E01900')
194
+ raise BadStatus, "Broken status response: expected to start with @E01900, actual #{data[0..6]}"
195
+ end
196
+ @status_string = data
197
+ @status = {
198
+ # RX-V1500: model R0177
199
+ model_code: @model_code,
200
+ firmware_version: @version,
201
+ system_status: data[7].ord - ZERO_ORD,
202
+ power: power = data[8].ord - ZERO_ORD,
203
+ main_power: [1, 4, 5, 2].include?(power),
204
+ zone2_power: [1, 4, 3, 6].include?(power),
205
+ zone3_power: [1, 5, 3, 7].include?(power),
206
+ }
207
+ if data.length > 9
208
+ @status.update(
209
+ input: input = data[9],
210
+ input_name: MAIN_INPUTS_GET.fetch(input),
211
+ multi_ch_input: data[10] == '1',
212
+ audio_select: audio_select = data[11],
213
+ audio_select_name: AUDIO_SELECT_GET.fetch(audio_select),
214
+ mute: data[12] == '1',
215
+ # Volume values (0.5 dB increment):
216
+ # mute: 0
217
+ # -80.0 dB (min): 39
218
+ # 0 dB: 199
219
+ # +14.5 dB (max): 228
220
+ # Zone2 volume values (1 dB increment):
221
+ # mute: 0
222
+ # -33 dB (min): 39
223
+ # 0 dB (max): 72
224
+ main_volume: volume = data[15..16].to_i(16),
225
+ main_volume_db: int_to_half_db(volume),
226
+ zone2_volume: zone2_volume = data[17..18].to_i(16),
227
+ zone2_volume_db: int_to_full_db(zone2_volume),
228
+ zone3_volume: zone3_volume = data[129..130].to_i(16),
229
+ zone3_volume_db: int_to_full_db(zone3_volume),
230
+ program: program = data[19..20],
231
+ program_name: PROGRAM_GET.fetch(program),
232
+ # true: straight; false: effect
233
+ effect: data[21] == '1',
234
+ #extended_surround: data[22],
235
+ #short_message: data[23],
236
+ sleep: SLEEP_GET.fetch(data[24]),
237
+ night: night = data[27],
238
+ night_name: NIGHT_GET.fetch(night),
239
+ pure_direct: data[28] == '1',
240
+ speaker_a: data[29] == '1',
241
+ speaker_b: data[30] == '1',
242
+ format: data[31..32],
243
+ sample_rate: data[33..34],
244
+ )
245
+ end
246
+ @status
247
+ end
248
+
249
+ def remote_command(cmd)
250
+ dispatch("#{STX}0#{cmd}#{ETX}")
251
+ end
252
+
253
+ def system_command(cmd)
254
+ dispatch("#{STX}2#{cmd}#{ETX}")
255
+ end
256
+
257
+ def extract_text(resp)
258
+ # TODO: assert resp[0] == DC1, resp[-1] == ETX
259
+ resp[0...-1]
260
+ end
261
+
262
+ def int_to_half_db(value)
263
+ if value == 0
264
+ :mute
265
+ else
266
+ (value - 39) / 2.0 - 80
267
+ end
268
+ end
269
+
270
+ def int_to_full_db(value)
271
+ if value == 0
272
+ :mute
273
+ else
274
+ (value - 39) - 33
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'logger'
5
+ require 'pp'
6
+ require 'seriamp/utils'
7
+ require 'seriamp/detect'
8
+ require 'seriamp/yamaha/client'
9
+
10
+ module Seriamp
11
+ module Yamaha
12
+ class Cmd
13
+ def initialize(args)
14
+ options = {}
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: yamaha [-d device] command arg..."
17
+
18
+ opts.on("-d", "--device DEVICE", "TTY to use (default autodetect)") do |v|
19
+ options[:device] = v
20
+ end
21
+ end.parse!
22
+
23
+ @options = options
24
+
25
+ @logger = Logger.new(STDERR)
26
+ @client = Yamaha::Client.new(device: options[:device], logger: @logger)
27
+
28
+ @args = args
29
+ end
30
+
31
+ attr_reader :args
32
+ attr_reader :logger
33
+
34
+ def run
35
+ if args.any?
36
+ run_command(args)
37
+ else
38
+ STDIN.each_line do |line|
39
+ line.strip!
40
+ line.sub!(/#.*/, '')
41
+ next if line.empty?
42
+
43
+ run_command(line.strip.split(%r,\s+,))
44
+ end
45
+ end
46
+ end
47
+
48
+ def run_command(args)
49
+ cmd = args.shift
50
+ unless cmd
51
+ raise ArgumentError, "No command given"
52
+ end
53
+
54
+ case cmd
55
+ when 'detect'
56
+ device = Seriamp.detect_device(Yamaha, *args, logger: logger)
57
+ if device
58
+ puts device
59
+ exit 0
60
+ else
61
+ STDERR.puts("Yamaha receiver not found")
62
+ exit 3
63
+ end
64
+ when 'power'
65
+ which = ARGV.shift&.downcase
66
+ if %w(main zone2 zone3).include?(which)
67
+ method = "set_#{which}_power"
68
+ state = Utils.parse_on_off(ARGV.shift)
69
+ else
70
+ method = 'set_power'
71
+ state = Utils.parse_on_off(which)
72
+ end
73
+ client.public_send(method, state)
74
+ when 'volume'
75
+ which = ARGV.shift
76
+ if %w(main zone2 zone3).include?(which)
77
+ prefix = "set_#{which}"
78
+ value = ARGV.shift
79
+ else
80
+ prefix = 'set_main'
81
+ value = which
82
+ end
83
+ if %w(. -).include?(value)
84
+ method = "#{prefix}_mute"
85
+ value = true
86
+ else
87
+ method = "#{prefix}_volume_db"
88
+ if value[0] == ','
89
+ value = value[1..]
90
+ end
91
+ value = Float(value)
92
+ end
93
+ client.public_send(method, value)
94
+ p client.get_main_volume_text
95
+ p client.get_zone2_volume_text
96
+ p client.get_zone3_volume_text
97
+ when 'input'
98
+ which = ARGV.shift&.downcase
99
+ if %w(main zone2 zone3).include?(which)
100
+ method = "set_#{which}_input"
101
+ input = ARGV.shift
102
+ else
103
+ method = 'set_main_input'
104
+ input = which
105
+ end
106
+ client.public_send(method, input)
107
+ when 'program'
108
+ value = ARGV.shift.downcase
109
+ client.set_program(value)
110
+ when 'pure-direct'
111
+ state = Utils.parse_on_off(ARGV.shift)
112
+ client.set_pure_direct(state)
113
+ when 'status'
114
+ pp client.last_status
115
+ when 'status_string'
116
+ puts client.last_status_string
117
+ when 'test'
118
+ client.set_power(false)
119
+ [true, false].each do |main_state|
120
+ [true, false].each do |zone2_state|
121
+ [true, false].each do |zone3_state|
122
+ client.set_main_power(main_state)
123
+ client.set_zone2_power(zone2_state)
124
+ client.set_zone3_power(zone3_state)
125
+ puts "#{main_state ?1:0} #{zone2_state ?1:0} #{zone3_state ?1:0} #{client.status[:power]}"
126
+ end
127
+ end
128
+ end
129
+ else
130
+ raise ArgumentError, "Unknown command: #{cmd}"
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ attr_reader :client
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seriamp
4
+ module Yamaha
5
+ module Protocol
6
+ module Constants
7
+
8
+ private
9
+
10
+ PROGRAM_SET = {
11
+ 'munich' => 'E1',
12
+ 'vienna' => 'E5',
13
+ 'amsterdam' => 'E6',
14
+ 'freiburg' => 'E8',
15
+ 'chamber' => 'AF',
16
+ 'village_vanguard' => 'EB',
17
+ 'warehouse_loft' => 'EE',
18
+ 'cellar_club' => 'CD',
19
+ 'the_bottom_line' => 'EC',
20
+ 'the_roxy_theatre' => 'ED',
21
+ 'disco' => 'F0',
22
+ 'game' => 'F2',
23
+ '7ch_stereo' => 'FF',
24
+ '2ch_stereo' => 'C0',
25
+ 'sports' => 'F8',
26
+ 'action_game' => 'F2',
27
+ 'roleplaying_game' => 'CE',
28
+ 'music_video' => 'F3',
29
+ 'recital_opera' => 'F5',
30
+ 'standard' => 'FE',
31
+ 'spectacle' => 'F9',
32
+ 'sci-fi' => 'FA',
33
+ 'adventure' => 'FB',
34
+ 'drama' => 'FC',
35
+ 'mono_movie' => 'F7',
36
+ 'surround_decode' => 'FD',
37
+ 'thx_cinema' => 'C2',
38
+ 'thx_music' => 'C3',
39
+ 'thx_game' => 'C8',
40
+ }.freeze
41
+
42
+ MAIN_INPUTS_SET = {
43
+ 'phono' => '14',
44
+ 'cd' => '15',
45
+ 'tuner' => '16',
46
+ 'cd_r' => '19',
47
+ 'md_tape' => '18',
48
+ 'dvd' => 'C1',
49
+ 'dtv' => '54',
50
+ 'cbl_sat' => 'C0',
51
+ 'vcr1' => '0F',
52
+ 'dvr_vcr2' => '13',
53
+ 'v_aux_dock' => '55',
54
+ 'multi_ch' => '87',
55
+ 'xm' => 'B4',
56
+ }.freeze
57
+
58
+ ZONE2_INPUTS_SET = {
59
+ 'phono' => 'D0',
60
+ 'cd' => 'D1',
61
+ 'tuner' => 'D2',
62
+ 'cd_r' => 'D4',
63
+ 'md_tape' => 'D3',
64
+ 'dvd' => 'CD',
65
+ 'dtv' => 'D9',
66
+ 'cbl_sat' => 'CC',
67
+ 'vcr1' => 'D6',
68
+ 'dvr_vcr2' => 'D7',
69
+ 'v_aux_dock' => 'D8',
70
+ 'xm' => 'B8',
71
+ }.freeze
72
+
73
+ ZONE3_INPUTS_SET = {
74
+ 'phono' => 'F1',
75
+ 'cd' => 'F2',
76
+ 'tuner' => 'F3',
77
+ 'cd_r' => 'F5',
78
+ 'md_tape' => 'F4',
79
+ 'dvd' => 'FC',
80
+ 'dtv' => 'F6',
81
+ 'cbl_sat' => 'F7',
82
+ 'vcr1' => 'F9',
83
+ 'dvr_vcr2' => 'FA',
84
+ 'v_aux_dock' => 'F0',
85
+ 'xm' => 'B9',
86
+ }.freeze
87
+
88
+ MAIN_INPUTS_GET = {
89
+ '0' => 'PHONO',
90
+ '1' => 'CD',
91
+ '2' => 'TUNER',
92
+ '3' => 'CD-R',
93
+ '4' => 'MD/TAPE',
94
+ '5' => 'DVD',
95
+ '6' => 'DTV',
96
+ '7' => 'CBL/SAT',
97
+ '8' => 'SAT',
98
+ '9' => 'VCR1',
99
+ 'A' => 'DVR/VCR2',
100
+ 'B' => 'VCR3/DVR',
101
+ 'C' => 'V-AUX/DOCK',
102
+ 'D' => 'NET/USB',
103
+ 'E' => 'XM',
104
+ }.freeze
105
+
106
+ AUDIO_SELECT_GET = {
107
+ '0' => 'Auto', # Confirmed RX-V1500
108
+ '2' => 'DTS', # Confirmed RX-V1500
109
+ '3' => 'Coax / Opt', # Unconfirmed
110
+ '4' => 'Analog', # Confirmed RX-V1500
111
+ '5' => 'Analog Only', # Unconfirmed
112
+ '8' => 'HDMI', # Unconfirmed
113
+ }.freeze
114
+
115
+ NIGHT_GET = {
116
+ '0' => 'Off',
117
+ '1' => 'Cinema',
118
+ '2' => 'Music',
119
+ }.freeze
120
+
121
+ SLEEP_GET = {
122
+ '0' => 120,
123
+ '1' => 90,
124
+ '2' => 60,
125
+ '3' => 30,
126
+ '4' => nil,
127
+ }.freeze
128
+
129
+ PROGRAM_GET = {
130
+ '00' => 'Munich',
131
+ '01' => 'Hall B',
132
+ '02' => 'Hall C',
133
+ '04' => 'Hall D',
134
+ '05' => 'Vienna',
135
+ '06' => 'Live Concert',
136
+ '07' => 'Hall in Amsterdam',
137
+ '08' => 'Tokyo',
138
+ '09' => 'Freiburg',
139
+ '0A' => 'Royaumont',
140
+ '0B' => 'Chamber',
141
+ '0C' => 'Village Gate',
142
+ '0D' => 'Village Vanguard',
143
+ '0E' => 'The Bottom Line',
144
+ '0F' => 'Cellar Club',
145
+ '10' => 'The Roxy Theater',
146
+ '11' => 'Warehouse Loft',
147
+ '12' => 'Arena',
148
+ '14' => 'Disco',
149
+ '15' => 'Party',
150
+ '17' => '7ch Stereo',
151
+ '18' => 'Music Video',
152
+ '19' => 'DJ',
153
+ '1C' => 'Recital/Opera',
154
+ '1D' => 'Pavilion',
155
+ '1E' => 'Action Gamae',
156
+ '1F' => 'Role Playing Game',
157
+ '20' => 'Mono Movie',
158
+ '21' => 'Sports',
159
+ '24' => 'Spectacle',
160
+ '25' => 'Sci-Fi',
161
+ '28' => 'Adventure',
162
+ '29' => 'Drama',
163
+ '2C' => 'Surround Decode',
164
+ '2D' => 'Standard',
165
+ '30' => 'PLII Movie',
166
+ '31' => 'PLII Music',
167
+ '32' => 'Neo:6 Movie',
168
+ '33' => 'Neo:6 Music',
169
+ '34' => '2ch Stereo',
170
+ '35' => 'Direct Stereo',
171
+ '36' => 'THX Cinema',
172
+ '37' => 'THX Music',
173
+ '3C' => 'THX Game',
174
+ '40' => 'Enhancer 2ch Low',
175
+ '41' => 'Enhancer 2ch High',
176
+ '42' => 'Enhancer 7ch Low',
177
+ '43' => 'Enhancer 7ch High',
178
+ '80' => 'Straight',
179
+ }.freeze
180
+ end
181
+ end
182
+ end
183
+ end