seriamp 0.1.14 → 0.2.0

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.
@@ -22,6 +22,13 @@ module Seriamp
22
22
  render_json(client.get_zone_power)
23
23
  end
24
24
 
25
+ post '/off' do
26
+ 1.upto(4) do |zone|
27
+ client.set_zone_power(zone, false)
28
+ end
29
+ render_json(client.get_zone_power)
30
+ end
31
+
25
32
  get '/volume' do
26
33
  payload = {
27
34
  zone_volume: client.get_zone_volume,
@@ -46,7 +53,7 @@ module Seriamp
46
53
  end
47
54
 
48
55
  put '/zone/:zone/volume' do |zone|
49
- volume = request.body.read.to_i
56
+ volume = Integer(request.body.read)
50
57
  client.set_zone_volume(Integer(zone), volume)
51
58
  end
52
59
 
@@ -60,7 +67,7 @@ module Seriamp
60
67
  end
61
68
 
62
69
  put '/channel/:channel/volume' do |channel|
63
- volume = request.body.read.to_i
70
+ volume = Integer(request.body.read)
64
71
  client.set_channel_volume(Integer(channel), volume)
65
72
  end
66
73
 
@@ -70,13 +77,19 @@ module Seriamp
70
77
  end
71
78
 
72
79
  post '/' do
80
+ executor = Executor.new
81
+ request.body.read.split("\n").each do |line|
82
+ args = line.strip.split(/\s+/)
83
+ executor.run_command(args)
84
+ end
85
+ render_json({})
73
86
  end
74
87
 
75
88
  private
76
89
 
77
90
  def client
78
91
  settings.client || begin
79
- @client ||= Sonamp::Client.new(settings.device,
92
+ @client ||= Sonamp::Client.new(device: settings.device,
80
93
  logger: settings.logger, retries: settings.retries, thread_safe: true)
81
94
  end
82
95
  end
@@ -0,0 +1,167 @@
1
+ autoload :JSON, 'json'
2
+ autoload :FileUtils, 'fileutils'
3
+ require 'seriamp/faraday_facade'
4
+ require 'seriamp/utils'
5
+
6
+ module Seriamp
7
+ module Sonamp
8
+ class AutoPower
9
+ def initialize(**opts)
10
+ @options = opts.dup.freeze
11
+
12
+ unless options[:sonamp_url]
13
+ raise ArgumentError, 'Sonamp URL is required'
14
+ end
15
+ unless options[:yamaha_url]
16
+ raise ArgumentError, 'Yamaha URL is required'
17
+ end
18
+ end
19
+
20
+ attr_reader :options
21
+
22
+ def logger
23
+ options[:logger]
24
+ end
25
+
26
+ def run
27
+ if (state_path = options[:state_path]) && File.exist?(state_path)
28
+ begin
29
+ @stored_sonamp_power = File.open(state_path) do |f|
30
+ JSON.load(f)
31
+ end
32
+ rescue JSON::ParserError => exc
33
+ logger&.warn("Failed to load state: #{exc.class}: #{exc}")
34
+ end
35
+ end
36
+
37
+ bump('application start')
38
+
39
+ prev_sonamp_power = nil
40
+ prev_sonamp_on = nil
41
+ handle_exceptions do
42
+ prev_sonamp_power = sonamp_client.get_json('power')
43
+ store_sonamp_power(prev_sonamp_power, prev_sonamp_power)
44
+ prev_sonamp_on = prev_sonamp_power.values.any? { |v| v == true }
45
+ end
46
+
47
+ loop do
48
+ sonamp_power = nil
49
+ sonamp_on = nil
50
+ handle_exceptions do
51
+ sonamp_power = sonamp_client.get_json('power')
52
+ sonamp_on = sonamp_power.values.any? { |v| v == true }
53
+ if sonamp_on && prev_sonamp_on == false
54
+ bump('amplifier turned on')
55
+ end
56
+ store_sonamp_power(prev_sonamp_power, sonamp_power)
57
+ prev_sonamp_power = sonamp_power
58
+ prev_sonamp_on = sonamp_on
59
+ end
60
+
61
+ # If we cannot query the receiver, assume it is on to prevent unintended
62
+ # turn-offs.
63
+ receiver_power = nil
64
+ handle_exceptions do
65
+ receiver_power = case resp = yamaha_client.get!('power')
66
+ when 'true'
67
+ true
68
+ when 'false'
69
+ false
70
+ else
71
+ raise "Unknown yamaha power response: #{resp}"
72
+ end
73
+ end
74
+ case receiver_power
75
+ when true
76
+ bump('receiver is on')
77
+ if sonamp_on == false
78
+ puts("turning on amplifier")
79
+ sonamp_set_on
80
+ end
81
+ when nil
82
+ bump('failed to communicate with receiver - assuming it is on')
83
+ end
84
+
85
+ delta = (@alive_through - Utils.monotime).to_i
86
+ if delta < 0 && sonamp_on
87
+ logger&.info("Turning amplifier off")
88
+ handle_exceptions do
89
+ sonamp_client.post!('off')
90
+ end
91
+ elsif ttl > 0
92
+ puts "TTL: #{delta / 60}:#{'%02d' % (delta % 60)}"
93
+ end
94
+
95
+ sleep 20
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ attr_reader :stored_sonamp_power
102
+
103
+ def handle_exceptions
104
+ yield
105
+ rescue Interrupt, SystemExit, NoMemoryError
106
+ raise
107
+ rescue => exc
108
+ logger&.warn("Unhandled exception: #{exc.class}: #{exc}")
109
+ end
110
+
111
+ def bump(reason)
112
+ if ttl > 0
113
+ logger&.debug("Bumping #{ttl} seconds: #{reason}")
114
+ end
115
+ @alive_through = Utils.monotime + ttl*60
116
+ end
117
+
118
+ def sonamp_client
119
+ @sonamp_client ||= FaradayFacade.new(
120
+ url: options.fetch(:sonamp_url),
121
+ timeout: options[:sonamp_timeout] || 3,
122
+ )
123
+ end
124
+
125
+ def yamaha_client
126
+ @yamaha_client ||= FaradayFacade.new(
127
+ url: options.fetch(:yamaha_url),
128
+ timeout: options[:yamaha_timeout] || 5,
129
+ )
130
+ end
131
+
132
+ def ttl
133
+ @ttl ||= begin
134
+ options[:ttl] || 0
135
+ end
136
+ end
137
+
138
+ def store_sonamp_power(prev_sonamp_power, sonamp_power)
139
+ # Wait for the power state to be stable - both readings should be
140
+ # the same.
141
+ if prev_sonamp_power == sonamp_power && sonamp_power.values.any? { |v| v == true }
142
+ @stored_sonamp_power = sonamp_power
143
+ if state_path = options[:state_path]
144
+ File.open(state_path + '.part', 'w') do |f|
145
+ f << JSON.dump(sonamp_power)
146
+ end
147
+ FileUtils.mv(state_path + '.part', state_path)
148
+ end
149
+ end
150
+ end
151
+
152
+ def sonamp_set_on
153
+ if stored_sonamp_power
154
+ logger&.debug("sonamp on")
155
+ stored_sonamp_power.each do |zone, value|
156
+ if value
157
+ logger&.debug("Sonamp: zone #{zone} on")
158
+ sonamp_client.put!("zone/#{zone}/power", body: 'true')
159
+ end
160
+ end
161
+ else
162
+ logger&.warn("No stored sonamp power")
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'timeout'
4
4
  require 'seriamp/error'
5
- require 'seriamp/backend/serial_port'
5
+ require 'seriamp/backend'
6
6
 
7
7
  module Seriamp
8
8
  module Sonamp
@@ -224,14 +224,8 @@ module Seriamp
224
224
  logger&.debug("Opening #{device}")
225
225
  @io = Backend::SerialPortBackend::Device.new(device, logger: logger)
226
226
 
227
- warned = false
228
- while IO.select([@io.io], nil, nil, 0)
229
- unless warned
230
- logger&.warn("Serial device readable after opening - unread previous response?")
231
- warned = true
232
- end
233
- IO.read(1)
234
- end
227
+ Utils.consume_data(@io.io, logger,
228
+ "Serial device readable after opening - unread previous response?")
235
229
 
236
230
  begin
237
231
  yield @io
@@ -275,9 +269,8 @@ module Seriamp
275
269
  read_line(@io, cmd)
276
270
  end
277
271
 
278
- if @io && IO.select([@io.io], nil, nil, 0)
279
- logger&.warn("Serial device readable after completely reading status response - concurrent access?")
280
- end
272
+ Utils.consume_data(@io.io, logger,
273
+ "Serial device readable after completely reading status response - concurrent access?")
281
274
 
282
275
  if resp_lines_range_or_count == 1
283
276
  resp.first
@@ -318,15 +311,23 @@ module Seriamp
318
311
  def read_line(f, cmd)
319
312
  with_timeout do
320
313
  resp = +''
314
+ deadline = Utils.monotime + 1
321
315
  loop do
322
- ch = f.sysread(1)
323
- if ch
324
- break if ch == ?\r
325
- resp << ch
326
- else
327
- sleep 0.1
316
+ begin
317
+ buf = f.read_nonblock(1024)
318
+ if buf
319
+ resp += buf
320
+ break if buf[-1] == ?\r
321
+ end
322
+ rescue IO::WaitReadable
323
+ budget = deadline - Utils.monotime
324
+ if budget < 0
325
+ raise CommunicationTimeout
326
+ end
327
+ IO.select([f.io], nil, nil, budget)
328
328
  end
329
329
  end
330
+ resp.strip!
330
331
  if resp == 'ERR'
331
332
  raise InvalidCommand, "Invalid command: #{cmd}"
332
333
  elsif resp == 'N/A'
@@ -5,11 +5,12 @@ require 'logger'
5
5
  require 'seriamp/utils'
6
6
  require 'seriamp/detect'
7
7
  require 'seriamp/sonamp/client'
8
+ require 'seriamp/sonamp/executor'
8
9
 
9
10
  module Seriamp
10
11
  module Sonamp
11
12
  class Cmd
12
- def initialize(args)
13
+ def initialize(args = ARGV, stdin = STDIN)
13
14
  args = args.dup
14
15
 
15
16
  options = {}
@@ -27,16 +28,18 @@ module Seriamp
27
28
  @client = Sonamp::Client.new(device: options[:device], logger: @logger)
28
29
 
29
30
  @args = args
31
+ @stdin = stdin
30
32
  end
31
33
 
32
34
  attr_reader :args
35
+ attr_reader :stdin
33
36
  attr_reader :logger
34
37
 
35
38
  def run
36
39
  if args.any?
37
40
  run_command(args)
38
41
  else
39
- STDIN.each_line do |line|
42
+ stdin.each_line do |line|
40
43
  line.strip!
41
44
  line.sub!(/#.*/, '')
42
45
  next if line.empty?
@@ -54,43 +57,18 @@ module Seriamp
54
57
 
55
58
  case cmd
56
59
  when 'detect'
57
- device = Seriamp.detect_device(Sonamp, *args, logger: logger)
60
+ device = Seriamp.detect_device(Yamaha, *args, logger: logger)
58
61
  if device
59
62
  puts device
60
63
  exit 0
61
64
  else
62
- STDERR.puts("Sonamp amplifier not found")
65
+ STDERR.puts("Yamaha receiver not found")
63
66
  exit 3
64
67
  end
65
- when 'off'
66
- client.set_zone_power(1, false)
67
- client.set_zone_power(2, false)
68
- client.set_zone_power(3, false)
69
- client.set_zone_power(4, false)
70
- when 'power'
71
- zone = args.shift.to_i
72
- state = Utils.parse_on_off(args.shift)
73
- client.set_zone_power(zone, state)
74
- when 'zvol'
75
- zone = args.shift.to_i
76
- volume = args.shift.to_i
77
- client.set_zone_volume(zone, volume)
78
- when 'cvol'
79
- channel = args.shift.to_i
80
- volume = args.shift.to_i
81
- client.set_channel_volume(channel, volume)
82
- when 'zmute'
83
- zone = args.shift.to_i
84
- mute = args.shift.to_i
85
- client.set_zone_mute(zone, mute)
86
- when 'cmute'
87
- channel = args.shift.to_i
88
- mute = args.shift.to_i
89
- client.set_channel_mute(channel, mute)
90
68
  when 'status'
91
69
  pp client.status
92
70
  else
93
- raise ArgumentError, "Unknown command: #{cmd}"
71
+ executor.run_command(cmd, *args)
94
72
  end
95
73
  end
96
74
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seriamp
4
+ module Sonamp
5
+ class Executor
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ attr_reader :client
11
+
12
+ def run_command(cmd, *args)
13
+ case cmd
14
+ when 'detect'
15
+ device = Seriamp.detect_device(Sonamp, *args, logger: logger)
16
+ if device
17
+ puts device
18
+ exit 0
19
+ else
20
+ STDERR.puts("Yamaha receiver not found")
21
+ exit 3
22
+ end
23
+ when 'off'
24
+ client.set_zone_power(1, false)
25
+ client.set_zone_power(2, false)
26
+ client.set_zone_power(3, false)
27
+ client.set_zone_power(4, false)
28
+ when 'power'
29
+ zone = Integer(args.shift)
30
+ state = Utils.parse_on_off(args.shift)
31
+ client.set_zone_power(zone, state)
32
+ when 'zvol'
33
+ zone = Integer(args.shift)
34
+ volume = Integer(args.shift)
35
+ client.set_zone_volume(zone, volume)
36
+ when 'cvol'
37
+ channel = Integer(args.shift)
38
+ volume = Integer(args.shift)
39
+ client.set_channel_volume(channel, volume)
40
+ when 'zmute'
41
+ zone = Integer(args.shift)
42
+ mute = Integer(args.shift)
43
+ client.set_zone_mute(zone, mute)
44
+ when 'cmute'
45
+ channel = Integer(args.shift)
46
+ mute = Integer(args.shift)
47
+ client.set_channel_mute(channel, mute)
48
+ when 'status'
49
+ client.status
50
+ else
51
+ raise ArgumentError, "Unknown command: #{cmd}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/seriamp/utils.rb CHANGED
@@ -17,5 +17,22 @@ module Seriamp
17
17
  module_function def monotime
18
18
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
19
  end
20
+
21
+ module_function def consume_data(io, logger, msg)
22
+ warned = false
23
+ read_bytes = 0
24
+ while IO.select([io], nil, nil, 0)
25
+ unless warned
26
+ logger&.warn(msg)
27
+ warned = true
28
+ end
29
+ buf = io.read_nonblock(1024)
30
+ read_bytes += buf.length
31
+ end
32
+ if read_bytes > 0
33
+ logger&.warn("Consumed #{read_bytes} bytes")
34
+ end
35
+ nil
36
+ end
20
37
  end
21
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Seriamp
4
- VERSION = '0.1.14'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'timeout'
4
4
  require 'seriamp/utils'
5
- require 'seriamp/backend/serial_port'
5
+ require 'seriamp/backend'
6
6
  require 'seriamp/yamaha/protocol/methods'
7
7
 
8
8
  module Seriamp
@@ -197,14 +197,8 @@ module Seriamp
197
197
  logger&.debug("Opening #{device}")
198
198
  @io = Backend::SerialPortBackend::Device.new(device, logger: logger)
199
199
 
200
- warned = false
201
- while IO.select([@io.io], nil, nil, 0)
202
- unless warned
203
- logger&.warn("Serial device readable after opening - unread previous response?")
204
- warned = true
205
- end
206
- IO.read(1)
207
- end
200
+ Utils.consume_data(@io.io, logger,
201
+ "Serial device readable after opening - unread previous response?")
208
202
 
209
203
  begin
210
204
  tries = 0
@@ -251,15 +245,20 @@ module Seriamp
251
245
 
252
246
  def read_response
253
247
  resp = +''
254
- Timeout.timeout(2, CommunicationTimeout) do
255
- loop do
256
- ch = @io.sysread(1)
257
- if ch
258
- resp << ch
259
- break if ch == ETX
260
- else
261
- sleep 0.1
248
+ deadline = Utils.monotime + 1
249
+ loop do
250
+ begin
251
+ chunk = @io.read_nonblock(1000)
252
+ if chunk
253
+ resp += chunk
254
+ break if chunk[-1] == ETX
262
255
  end
256
+ rescue IO::WaitReadable
257
+ budget = deadline - Utils.monotime
258
+ if budget < 0
259
+ raise CommunicationTimeout
260
+ end
261
+ IO.select([@io.io], nil, nil, budget)
263
262
  end
264
263
  end
265
264
  resp
@@ -296,16 +295,9 @@ module Seriamp
296
295
  def do_status
297
296
  with_retry do
298
297
  resp = nil
299
- loop do
300
- resp = dispatch(STATUS_REQ)
301
- again = false
302
- while @io && IO.select([@io.io], nil, nil, 0)
303
- logger&.warn("Serial device readable after completely reading status response - concurrent access?")
304
- read_response
305
- again = true
306
- end
307
- break unless again
308
- end
298
+ resp = dispatch(STATUS_REQ)
299
+ Utils.consume_data(@io.io, logger,
300
+ "Serial device readable after completely reading status response - concurrent access?")
309
301
  if resp.length < 10
310
302
  raise HandshakeFailure, "Broken status response: expected at least 10 bytes, got #{resp.length} bytes; concurrent operation on device?"
311
303
  end
@@ -11,7 +11,7 @@ require 'seriamp/yamaha/executor'
11
11
  module Seriamp
12
12
  module Yamaha
13
13
  class Cmd
14
- def initialize(args)
14
+ def initialize(args = ARGV, stdin = STDIN)
15
15
  options = {}
16
16
  OptionParser.new do |opts|
17
17
  opts.banner = "Usage: yamaha [-d device] command arg..."
@@ -27,16 +27,18 @@ module Seriamp
27
27
  @client = Yamaha::Client.new(device: options[:device], logger: @logger)
28
28
 
29
29
  @args = args
30
+ @stdin = stdin
30
31
  end
31
32
 
32
33
  attr_reader :args
34
+ attr_reader :stdin
33
35
  attr_reader :logger
34
36
 
35
37
  def run
36
38
  if args.any?
37
39
  run_command(args)
38
40
  else
39
- STDIN.each_line do |line|
41
+ stdin.each_line do |line|
40
42
  line.strip!
41
43
  line.sub!(/#.*/, '')
42
44
  next if line.empty?
@@ -10,6 +10,7 @@ module Seriamp
10
10
  attr_reader :client
11
11
 
12
12
  def run_command(cmd, *args)
13
+ cmd = cmd.gsub('_', '-')
13
14
  case cmd
14
15
  when 'detect'
15
16
  device = Seriamp.detect_device(Yamaha, *args, logger: logger)
data/seriamp.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "seriamp"
5
- spec.version = '0.1.14'
5
+ spec.version = '0.2.0'
6
6
  spec.authors = ['Oleg Pudeyev']
7
7
  spec.email = ['code@olegp.name']
8
8
  spec.summary = %q{Serial control for amplifiers & A/V receivers}
@@ -15,7 +15,11 @@ Gem::Specification.new do |spec|
15
15
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16
16
  spec.require_paths = ["lib"]
17
17
 
18
- spec.add_runtime_dependency 'serialport', '~> 1.0'
18
+ spec.add_runtime_dependency 'serialport', '~> 1.3'
19
+
20
+ spec.add_development_dependency 'rspec-core', '~> 3.12'
21
+ spec.add_development_dependency 'rspec-expectations', '~> 3.12'
22
+ spec.add_development_dependency 'rspec-mocks', '~> 3.12'
19
23
 
20
24
  # Optional dependencies: sinatra for the web apps
21
25
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::App do
6
+ include Rack::Test::Methods
7
+
8
+ describe '#initialize' do
9
+ it 'works' do
10
+ described_class.new
11
+ end
12
+ end
13
+
14
+ let(:app) do
15
+ described_class.tap do |app|
16
+ app.client = client
17
+ end
18
+ end
19
+
20
+ let(:client_cls) { Seriamp::Sonamp::Client }
21
+ let(:client) { double('sonamp client') }
22
+
23
+ describe '/off' do
24
+ let(:final_state) do
25
+ {'1' => false, '2' => false, '3' => false, '4' => false}
26
+ end
27
+
28
+ it 'works' do
29
+
30
+ client.should receive(:set_zone_power).with(1, false)
31
+ client.should receive(:set_zone_power).with(2, false)
32
+ client.should receive(:set_zone_power).with(3, false)
33
+ client.should receive(:set_zone_power).with(4, false)
34
+ client.should receive(:get_zone_power).and_return(final_state)
35
+
36
+ post '/off'
37
+
38
+ last_response.status.should == 200
39
+ JSON.parse(last_response.body).should == final_state
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::Client do
6
+ describe '#initialize' do
7
+ it 'works' do
8
+ described_class.new
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Seriamp::Sonamp::Cmd do
6
+ describe '#initialize' do
7
+ it 'works' do
8
+ described_class.new
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RACK_ENV'] = 'test'
4
+
5
+ require 'byebug'
6
+ require 'seriamp/all'
7
+ require 'rack/test'
8
+
9
+ RSpec.configure do |rspec|
10
+ rspec.expect_with(:rspec) do |c|
11
+ c.syntax = [:should, :expect]
12
+ end
13
+ rspec.mock_with(:rspec) do |mocks|
14
+ mocks.syntax = [:should, :expect]
15
+ end
16
+ end