cgminer_api_client 0.2.6 → 0.3.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'cgminer_api_client/socket_with_timeout'
2
4
  require 'cgminer_api_client/miner/commands'
3
5
 
@@ -9,39 +11,51 @@ module CgminerApiClient
9
11
  attr_accessor :host, :port, :timeout
10
12
 
11
13
  def initialize(host = nil, port = nil, timeout = nil)
12
- @host = host ? host : CgminerApiClient.default_host
13
- @port = port ? port : CgminerApiClient.default_port
14
- @timeout = timeout ? timeout : CgminerApiClient.default_timeout
14
+ @host = host || CgminerApiClient.default_host
15
+ @port = port || CgminerApiClient.default_port
16
+ @timeout = timeout || CgminerApiClient.default_timeout
15
17
  end
16
18
 
17
19
  def query(method, *params)
18
- if available?
19
- request = {command: method}
20
-
21
- unless params.length == 0
22
- params = params.map { |p| p.to_s.gsub('\\', '\\\\').gsub(',', '\,') }
23
- request[:parameter] = params.join(',')
24
- end
25
-
26
- response = perform_request(request)
27
- data = sanitized(response)
28
- method.to_s.match('\+') ? data : data[method.to_sym]
20
+ request = { command: method }
21
+
22
+ unless params.empty?
23
+ # cgminer uses comma to separate parameters, so any literal commas in
24
+ # parameter values must be backslash-escaped, and any literal
25
+ # backslashes must themselves be doubled. The block form of gsub is
26
+ # used so the replacement string isn't interpreted (in gsub's
27
+ # replacement-string syntax, '\\' means a single literal backslash,
28
+ # which makes the obvious gsub('\\', '\\\\') a silent no-op).
29
+ params = params.map { |p| p.to_s.gsub('\\') { '\\\\' }.gsub(',') { '\\,' } }
30
+ request[:parameter] = params.join(',')
29
31
  end
32
+
33
+ response = perform_request(request)
34
+ data = sanitized(response)
35
+ method.to_s.match?('\+') ? data : data[method.to_sym]
30
36
  end
31
37
 
32
- def available?(force_reload = false)
33
- @available = nil if force_reload
38
+ # Reachability probe. Opens a fresh socket every call — no
39
+ # caching. Returns true on a successful connect, false on
40
+ # transport-level failure (DNS, refused, unreachable, timeout).
41
+ # Bugs like ArgumentError or NoMethodError propagate instead
42
+ # of being silently swallowed.
43
+ def available?
44
+ open_socket(@host, @port, @timeout).close
45
+ true
46
+ rescue SocketError, SystemCallError, CgminerApiClient::TimeoutError
47
+ false
48
+ end
34
49
 
35
- @available ||= begin
36
- open_socket(@host, @port, @timeout).close
37
- true
38
- rescue
39
- false
40
- end
50
+ def method_missing(name, *)
51
+ query(name, *)
41
52
  end
42
53
 
43
- def method_missing(name, *args)
44
- query(name, *args)
54
+ # method_missing forwards everything to query as a cgminer command,
55
+ # so respond to anything except names that look like Ruby internals
56
+ # or implicit conversion probes (to_ary, to_str, to_int, to_hash, ...).
57
+ def respond_to_missing?(name, _include_private = false)
58
+ !name.to_s.start_with?('to_', '_')
45
59
  end
46
60
 
47
61
  private
@@ -49,12 +63,12 @@ module CgminerApiClient
49
63
  def perform_request(request)
50
64
  begin
51
65
  s = open_socket(@host, @port, @timeout)
52
- rescue
53
- raise "Connection to #{@host}:#{@port} failed"
66
+ rescue StandardError => e
67
+ raise ConnectionError, "Connection to #{@host}:#{@port} failed: #{e.class}: #{e.message}"
54
68
  end
55
69
 
56
70
  s.write(request.to_json)
57
- response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : "\\u#{'%04x' % c.ord}" }.join
71
+ response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : format('\\u%04x', c.ord) }.join
58
72
  s.close
59
73
 
60
74
  response.gsub! '}{', '}, {'
@@ -62,37 +76,44 @@ module CgminerApiClient
62
76
 
63
77
  data = JSON.parse(response)
64
78
 
65
- if request[:command].to_s.match('\+')
66
- data.each_pair do |command, response|
79
+ if request[:command].to_s.match?('\+')
80
+ data.each_pair do |_command, response|
67
81
  check_status(response.first) if response.respond_to?(:first)
68
82
  end
69
83
  else
70
84
  check_status(data)
71
85
  end
72
86
 
73
- return data
87
+ data
74
88
  end
75
89
 
76
90
  def check_status(data)
77
- status = data['STATUS'][0]
91
+ status = data['STATUS'][0]
78
92
  sc = status['STATUS']
79
93
  c = status['Code']
80
94
  msg = status['Msg']
81
95
 
96
+ # cgminer STATUS codes: S=Success (silent), I=Info, W=Warning,
97
+ # E=Error, F=Fatal. Errors and Fatals raise ApiError so callers
98
+ # can distinguish them from ConnectionError (transport-level
99
+ # failures).
82
100
  case sc
83
- when 'S'
84
- when 'I'
85
- puts "Info from API [#{c}]: #{msg}"
86
- when 'W'
87
- puts "Warning from API [#{c}]: #{msg}"
88
- else
89
- raise "#{c}: #{msg}"
101
+ when 'S'
102
+ # no-op: success needs no notification
103
+ when 'I'
104
+ puts "Info from API [#{c}]: #{msg}"
105
+ when 'W'
106
+ puts "Warning from API [#{c}]: #{msg}"
107
+ else
108
+ raise ApiError, "#{c}: #{msg}"
90
109
  end
91
110
  end
92
111
 
93
112
  def sanitized(data)
94
113
  if data.is_a?(Hash)
95
- data.inject({}) { |n, (k, v)| n[k.to_s.downcase.tr(' ', '_').to_sym] = sanitized(v); n }
114
+ data.each_with_object({}) do |(k, v), n|
115
+ n[k.to_s.downcase.tr(' ', '_').to_sym] = sanitized(v)
116
+ end
96
117
  elsif data.is_a?(Array)
97
118
  data.map { |v| sanitized(v) }
98
119
  else
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CgminerApiClient
2
4
  class MinerPool
3
5
  include Miner::Commands
@@ -13,56 +15,100 @@ module CgminerApiClient
13
15
  load_miners!
14
16
  end
15
17
 
18
+ # Runs `method` against every miner in the pool in parallel and
19
+ # returns a PoolResult — an Enumerable wrapper around a
20
+ # MinerResult per miner, in pool order. Successes and failures
21
+ # are both captured structurally; this method does NOT write
22
+ # to stderr. Callers that want to display failures should
23
+ # iterate the result (or use PoolResult#errors).
16
24
  def query(method, *params)
17
- threads = @miners.collect do |miner|
25
+ threads = @miners.map do |miner|
18
26
  Thread.new do
19
- begin
20
- miner.query(method, *params)
21
- rescue => e
22
- $stderr.puts "#{e.class}: #{e}"
23
- []
24
- end
27
+ MinerResult.success(miner, miner.query(method, *params))
28
+ rescue StandardError => e
29
+ MinerResult.failure(miner, e)
25
30
  end
26
31
  end
27
- threads.each { |thr| thr.join }
28
- threads.collect(&:value)
32
+ threads.each(&:join)
33
+ PoolResult.new(threads.map(&:value))
34
+ end
35
+
36
+ # The Commands::ReadOnly convenience methods that unwrap
37
+ # single-element cgminer responses with `query(:name)[0]` make
38
+ # sense on a single Miner, but on a MinerPool they silently
39
+ # returned only the first miner's hash — a pre-existing latent
40
+ # bug masked by always calling .first on the result.
41
+ #
42
+ # Override them here to return a PoolResult where each
43
+ # successful MinerResult carries the unwrapped hash instead of
44
+ # the one-element array. Failures pass through unchanged.
45
+ %i[summary coin config version].each do |cmd|
46
+ define_method(cmd) do
47
+ unwrap_first(query(cmd))
48
+ end
49
+ end
50
+
51
+ def check(command)
52
+ unwrap_first(query(:check, command))
29
53
  end
30
54
 
31
- def available_miners(force_reload = false)
32
- threads = @miners.collect do |miner|
55
+ def available_miners
56
+ threads = @miners.map do |miner|
33
57
  Thread.new do
34
- begin
35
- miner if miner.available?(force_reload)
36
- rescue
37
- nil
38
- end
58
+ # Suppress Ruby's default "auto-print unhandled thread
59
+ # exceptions to stderr" behavior. Bugs that propagate
60
+ # here will be re-raised by Thread#value to the caller of
61
+ # #available_miners; we don't want them double-reported
62
+ # to stderr in the meantime.
63
+ Thread.current.report_on_exception = false
64
+ miner if miner.available?
65
+ rescue SocketError, SystemCallError, CgminerApiClient::TimeoutError
66
+ # Miner#available? already returns false for these; this
67
+ # rescue is defensive against a future refactor. Bugs
68
+ # (NoMethodError, ArgumentError) still propagate via
69
+ # Thread#value re-raising.
70
+ nil
39
71
  end
40
72
  end
41
- threads.each { |thr| thr.join }
42
- threads.collect(&:value).compact
73
+ threads.each(&:join)
74
+ threads.map(&:value).compact
43
75
  end
44
76
 
45
- def unavailable_miners(force_reload = false)
46
- @miners - available_miners(force_reload)
77
+ def unavailable_miners
78
+ @miners - available_miners
47
79
  end
48
80
 
49
- def method_missing(name, *args)
50
- query(name, *args)
81
+ def method_missing(name, *)
82
+ query(name, *)
83
+ end
84
+
85
+ # See Miner#respond_to_missing? for the rationale.
86
+ def respond_to_missing?(name, _include_private = false)
87
+ !name.to_s.start_with?('to_', '_')
51
88
  end
52
89
 
53
90
  private
54
91
 
92
+ # Rebuild a PoolResult where each successful MinerResult's
93
+ # value is replaced with value.first (i.e. the unwrapped
94
+ # single-element response). Failures pass through unchanged.
95
+ def unwrap_first(pool_result)
96
+ PoolResult.new(pool_result.results.map do |r|
97
+ r.ok? ? MinerResult.success(r.miner, r.value.first) : r
98
+ end)
99
+ end
100
+
55
101
  def load_miners!
56
102
  raise 'Please create config/miners.yml' unless File.exist?('config/miners.yml')
57
103
 
58
- miners_config = YAML.load_file('config/miners.yml')
59
- @miners = miners_config.collect{|miner|
104
+ miners_config = YAML.safe_load_file('config/miners.yml')
105
+ @miners = miners_config.collect do |miner|
60
106
  CgminerApiClient::Miner.new(
61
107
  miner['host'],
62
108
  miner['port'],
63
109
  miner['timeout']
64
110
  )
65
- }
111
+ end
66
112
  end
67
113
  end
68
- end
114
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CgminerApiClient
4
+ # A single per-miner outcome from a MinerPool query. Either a
5
+ # successful response (error is nil, value holds the parsed data)
6
+ # or a failure (value is nil, error holds the exception).
7
+ #
8
+ # Immutable value object backed by Data.define, which gives us
9
+ # ==, hash, eql?, inspect, to_h, and deconstruct_keys for pattern
10
+ # matching for free.
11
+ #
12
+ # Usage:
13
+ # result = pool.summary.first
14
+ # if result.ok?
15
+ # puts result.value[:mhs_av]
16
+ # else
17
+ # warn "#{result.miner.host}: #{result.error.message}"
18
+ # end
19
+ #
20
+ # Or with pattern matching:
21
+ # case result
22
+ # in { ok?: true, value: } then use(value)
23
+ # in { ok?: false, error: } then log(error)
24
+ # end
25
+ MinerResult = Data.define(:miner, :value, :error) do
26
+ def self.success(miner, value)
27
+ new(miner: miner, value: value, error: nil)
28
+ end
29
+
30
+ def self.failure(miner, error)
31
+ new(miner: miner, value: nil, error: error)
32
+ end
33
+
34
+ def ok?
35
+ error.nil?
36
+ end
37
+
38
+ def failed?
39
+ !ok?
40
+ end
41
+
42
+ # Re-raise the captured error, or return the value if successful.
43
+ # Equivalent to `result.ok? ? result.value : raise(result.error)`
44
+ # but shorter at call sites.
45
+ def raise!
46
+ raise error if failed?
47
+
48
+ value
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CgminerApiClient
4
+ # An Enumerable wrapper around Array<MinerResult> returned from
5
+ # every MinerPool query. Preserves miner order (position N in the
6
+ # pool maps to position N in the result). Callers can either
7
+ # iterate per-Result for full control, or use the high-level
8
+ # helpers for common cases.
9
+ #
10
+ # Usage:
11
+ # result = pool.summary
12
+ # result.values # [{mhs_av: ..., elapsed: ...}] — successes only
13
+ # result.errors # [<ConnectionError>, ...] — failures only
14
+ # result.successful # [<MinerResult>, ...] — ok? Results
15
+ # result.failed # [<MinerResult>, ...] — !ok? Results
16
+ # result.all_successful? # true if every miner succeeded
17
+ # result.any_succeeded? # true if at least one miner succeeded
18
+ # result.any_failed? # true if any miner failed
19
+ # result[0] # MinerResult at index 0
20
+ # result[miner] # MinerResult for a specific Miner instance
21
+ # result["10.0.0.1:4028"] # MinerResult by "host:port" string
22
+ # result.each { |r| r.miner.host } # iterates MinerResult instances
23
+ class PoolResult
24
+ include Enumerable
25
+
26
+ attr_reader :results
27
+
28
+ def initialize(results)
29
+ @results = results.freeze
30
+ end
31
+
32
+ def each(&)
33
+ @results.each(&)
34
+ end
35
+
36
+ def size
37
+ @results.size
38
+ end
39
+
40
+ def empty?
41
+ @results.empty?
42
+ end
43
+
44
+ def to_a
45
+ @results.dup
46
+ end
47
+
48
+ def values
49
+ @results.select(&:ok?).map(&:value)
50
+ end
51
+
52
+ def errors
53
+ @results.reject(&:ok?).map(&:error)
54
+ end
55
+
56
+ def successful
57
+ @results.select(&:ok?)
58
+ end
59
+
60
+ def failed
61
+ @results.reject(&:ok?)
62
+ end
63
+
64
+ def all_successful?
65
+ @results.all?(&:ok?)
66
+ end
67
+
68
+ def any_succeeded?
69
+ @results.any?(&:ok?)
70
+ end
71
+
72
+ def any_failed?
73
+ @results.any?(&:failed?)
74
+ end
75
+
76
+ # Lookup by integer index, Miner instance, or "host:port" string.
77
+ def [](key)
78
+ case key
79
+ when Integer then @results[key]
80
+ when String then @results.find { |r| "#{r.miner.host}:#{r.miner.port}" == key }
81
+ else @results.find { |r| r.miner == key }
82
+ end
83
+ end
84
+
85
+ def ==(other)
86
+ other.is_a?(self.class) && @results == other.results
87
+ end
88
+ alias eql? ==
89
+
90
+ def hash
91
+ @results.hash
92
+ end
93
+ end
94
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CgminerApiClient
2
4
  module SocketWithTimeout
3
5
  def open_socket(host, port, timeout)
@@ -10,21 +12,25 @@ module CgminerApiClient
10
12
  begin
11
13
  socket.connect_nonblock(sockaddr)
12
14
  rescue IO::WaitWritable
13
- if IO.select(nil, [socket], nil, timeout)
15
+ if socket.wait_writable(timeout)
14
16
  begin
15
17
  socket.connect_nonblock(sockaddr)
16
18
  rescue Errno::EISCONN
17
- # the socket is connected
18
- rescue
19
+ # On Linux, the second connect_nonblock on a now-writable
20
+ # socket reports EISCONN to mean "the connection completed
21
+ # while we were waiting." Treat as success. On other
22
+ # platforms the second call returns 0 cleanly and this
23
+ # branch never fires.
24
+ rescue StandardError
19
25
  socket.close
20
26
  raise
21
27
  end
22
28
  else
23
29
  socket.close
24
- raise "Connection timeout"
30
+ raise CgminerApiClient::TimeoutError, "Connection to #{host}:#{port} timed out after #{timeout}s"
25
31
  end
26
32
  end
27
33
  end
28
34
  end
29
35
  end
30
- end
36
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CgminerApiClient
2
- VERSION = "0.2.6"
4
+ VERSION = "0.3.0"
3
5
  end
@@ -1,13 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'socket'
3
- require 'thread'
4
5
  require 'yaml'
5
6
 
6
- require "cgminer_api_client/miner"
7
- require "cgminer_api_client/miner/commands"
8
- require "cgminer_api_client/miner_pool"
9
- require "cgminer_api_client/socket_with_timeout"
10
- require "cgminer_api_client/version"
7
+ require 'cgminer_api_client/errors'
8
+ require 'cgminer_api_client/miner_result'
9
+ require 'cgminer_api_client/pool_result'
10
+ require 'cgminer_api_client/miner'
11
+ require 'cgminer_api_client/miner_pool'
12
+ require 'cgminer_api_client/version'
11
13
 
12
14
  module CgminerApiClient
13
15
  def self.default_host
@@ -37,4 +39,4 @@ module CgminerApiClient
37
39
  def self.config
38
40
  yield self if block_given?
39
41
  end
40
- end
42
+ end
metadata CHANGED
@@ -1,16 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cgminer_api_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Ramos
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2019-12-15 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: ''
12
+ description: Ruby client for the cgminer JSON API. Supports querying a single miner
13
+ or a pool of miners in parallel, with full coverage of read-only and privileged
14
+ commands.
14
15
  email:
15
16
  - justin.ramos@gmail.com
16
17
  executables:
@@ -18,33 +19,29 @@ executables:
18
19
  extensions: []
19
20
  extra_rdoc_files: []
20
21
  files:
21
- - ".gitignore"
22
- - ".rspec"
23
- - ".travis.yml"
24
- - ".whitesource"
25
- - Gemfile
22
+ - CHANGELOG.md
26
23
  - LICENSE.txt
27
24
  - README.md
28
- - Rakefile
29
25
  - bin/cgminer_api_client
30
26
  - cgminer_api_client.gemspec
31
27
  - config/miners.yml.example
32
28
  - lib/cgminer_api_client.rb
29
+ - lib/cgminer_api_client/errors.rb
33
30
  - lib/cgminer_api_client/miner.rb
34
31
  - lib/cgminer_api_client/miner/commands.rb
35
32
  - lib/cgminer_api_client/miner_pool.rb
33
+ - lib/cgminer_api_client/miner_result.rb
34
+ - lib/cgminer_api_client/pool_result.rb
36
35
  - lib/cgminer_api_client/socket_with_timeout.rb
37
36
  - lib/cgminer_api_client/version.rb
38
- - spec/cgminer_api_client/miner/commands_spec.rb
39
- - spec/cgminer_api_client/miner_pool_spec.rb
40
- - spec/cgminer_api_client/miner_spec.rb
41
- - spec/cgminer_api_client_spec.rb
42
- - spec/spec_helper.rb
43
37
  homepage: https://github.com/jramos/cgminer_api_client
44
38
  licenses:
45
39
  - MIT
46
- metadata: {}
47
- post_install_message:
40
+ metadata:
41
+ source_code_uri: https://github.com/jramos/cgminer_api_client
42
+ changelog_uri: https://github.com/jramos/cgminer_api_client/blob/master/CHANGELOG.md
43
+ bug_tracker_uri: https://github.com/jramos/cgminer_api_client/issues
44
+ rubygems_mfa_required: 'true'
48
45
  rdoc_options: []
49
46
  require_paths:
50
47
  - lib
@@ -52,20 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
52
49
  requirements:
53
50
  - - ">="
54
51
  - !ruby/object:Gem::Version
55
- version: '0'
52
+ version: '3.2'
56
53
  required_rubygems_version: !ruby/object:Gem::Requirement
57
54
  requirements:
58
55
  - - ">="
59
56
  - !ruby/object:Gem::Version
60
57
  version: '0'
61
58
  requirements: []
62
- rubygems_version: 3.0.6
63
- signing_key:
59
+ rubygems_version: 4.0.6
64
60
  specification_version: 4
65
61
  summary: A gem that allows sending API commands to a pool of cgminer instances
66
- test_files:
67
- - spec/cgminer_api_client/miner/commands_spec.rb
68
- - spec/cgminer_api_client/miner_pool_spec.rb
69
- - spec/cgminer_api_client/miner_spec.rb
70
- - spec/cgminer_api_client_spec.rb
71
- - spec/spec_helper.rb
62
+ test_files: []
data/.gitignore DELETED
@@ -1,16 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
10
- *.bundle
11
- *.so
12
- *.o
13
- *.a
14
- mkmf.log
15
- config/miners.yml
16
- ._*
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --color
2
- --warnings
3
- --require spec_helper
data/.travis.yml DELETED
@@ -1,5 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - 2.6
4
- notifications:
5
- email: false
data/.whitesource DELETED
@@ -1,8 +0,0 @@
1
- ##########################################################
2
- #### WhiteSource "Bolt for Github" configuration file ####
3
- ##########################################################
4
-
5
- # Configuration #
6
- #---------------#
7
- ws.repo.scan=true
8
- vulnerable.check.run.conclusion.level=failure
data/Gemfile DELETED
@@ -1,10 +0,0 @@
1
- source 'https://rubygems.org'
2
-
3
- gemspec
4
-
5
- group :development do
6
- gem 'pry' , '>= 0.12.0'
7
- gem 'rake' , '>= 13.0.0'
8
- gem 'rspec' , '>= 3.9'
9
- gem 'simplecov' , '>= 0.17.0'
10
- end
data/Rakefile DELETED
@@ -1,7 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require 'rspec/core/rake_task'
3
-
4
- RSpec::Core::RakeTask.new(:spec)
5
-
6
- task :default => :spec
7
- task :test => :spec