cgminer_api_client 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 129410d116f95439ef734a1ae6265c047eb7f95007755e5788fc416e78741388
4
- data.tar.gz: 386bd4f91ce093e290777a1b00be6efaa173f03f951b8bcc00d4b8683ce0c576
3
+ metadata.gz: 97da0040fa9d47c7d6a8157e53a30decf6e62ca216e3fb50af638dbcc6189d7c
4
+ data.tar.gz: aa30a0192cbf07991b90d904ecb476558ad509d15f001d0cc1f5b3daefc0f835
5
5
  SHA512:
6
- metadata.gz: d7ebd6316421e95755768cf0f77dd672e3860c2fd508fd2314cc898f46b9c76d39efc78d3b1f4ac422c873871e1896b5fb8859e681c8ff3712f5f60f12c85351
7
- data.tar.gz: 99a4578907f13668e8f0bf5cf466298f40774e318d3f470ad38df7f0cfa2455b51c028866084a264201156b6e30505daf7849e1d6a12aec4122ec5092d788fed
6
+ metadata.gz: 73458d32d4079dc3483967d9329cf5bbbb1be4a73cd6ef8e202e3872b36ae9c18eac60bfb5ce81befe575cb0085ec1fd6f49e7df28fd7386e432ad40f83d71a3
7
+ data.tar.gz: 7bcde1758bd4ce3a8f111c7d21fe931683158a284de7055d3eed1be3b91bafc1ee2f8694dbd53a14173383da35e5691f2be64091dd4dbf317e3a65dc66f70bbf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,105 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-04-25
11
+
12
+ ### Added
13
+ - **`CgminerApiClient::AccessDeniedError`**, a subclass of `ApiError`
14
+ for the most commonly dispatched-on case (cgminer Code 45 + the
15
+ `access_denied?` local guard). Inherits from `ApiError` so existing
16
+ `rescue ApiError` clauses still catch it; callers wanting finer
17
+ dispatch use `rescue AccessDeniedError`. Constructor pins
18
+ `code: :access_denied` so the symbolic tag is consistent. The
19
+ wire-side `Miner#check_status` delegates to a new factory
20
+ `ApiError.for_status(c, msg)` that picks the right subclass
21
+ based on the integer Code.
22
+ - **Structured error codes on `CgminerApiClient::ApiError`.** Two new
23
+ reader methods alongside the existing `#message`:
24
+ `#cgminer_code` (the integer Code from cgminer's STATUS hash —
25
+ e.g., `45` for access denied — or `nil` if the wire integer was
26
+ discarded, as happens when the `access_denied?` local guard's
27
+ call to `privileged` hits the wire and the rescue inside
28
+ `privileged` drops the integer before re-raising) and `#code`
29
+ (a symbolic tag derived from the integer via
30
+ `ApiError::CGMINER_CODES`, falling back to `:unknown` for codes
31
+ not in the map). Callers can now `case e.code; when :access_denied`
32
+ instead of parsing English error strings. **Prefer `e.code` for
33
+ dispatch over `e.cgminer_code`** — `cgminer_code` can be nil even
34
+ when the symbolic tag is set. The map is intentionally
35
+ conservative — `14 → :invalid_command`, `45 → :access_denied`,
36
+ the codes observed against real cgminer wire fixtures.
37
+ Backward-compatible: `raise ApiError, "msg"` still works and
38
+ `e.message` is unchanged.
39
+
40
+ ### Changed
41
+ - `ApiError` constructor now validates `cgminer_code:` is `Integer`
42
+ or `nil` and `code:` is `Symbol`, `String`, or `nil`. Bad input
43
+ raises `ArgumentError` with a clear message instead of silently
44
+ producing `code: :unknown` (string `cgminer_code`) or
45
+ `NoMethodError` deep in the constructor (`code: 42`). Wire-side
46
+ callers go through `ApiError.for_status` which coerces a
47
+ non-numeric Code to `nil` so dispatch falls through to `:unknown`.
48
+ - **`docs/logging.md`** — short stub stating that `cgminer_api_client`
49
+ is intentionally silent: no `Logger` module, no structured log
50
+ events. The library raises on failure and returns result objects
51
+ on success; callers (`cgminer_monitor`, `cgminer_manager`, the
52
+ operator CLIs) own the log call sites. Points at
53
+ `cgminer_monitor/docs/log_schema.md` for the cross-repo schema
54
+ contract and names the events (`poll.miner_failed`,
55
+ `poll.unexpected_error`) that surface api_client exception classes.
56
+ - **`bundle-audit` in CI** (`.github/workflows/ci.yml`). New `audit`
57
+ job runs `bundle exec bundle-audit check --update` on every push
58
+ and PR, gating merges on known CVEs in `Gemfile.lock`. Advisory
59
+ DB is refreshed on each run from `rubysec/ruby-advisory-db`. Also
60
+ available locally as `bundle exec rake audit`.
61
+ - **Dependabot config** (`.github/dependabot.yml`). Weekly bump PRs
62
+ for Bundler and GitHub Actions, with `open-pull-requests-limit: 3`
63
+ per ecosystem. `versioning-strategy: lockfile-only` on bundler, so
64
+ Gemfile / gemspec constraints are never auto-widened — humans
65
+ widen `~>` bounds intentionally. Targets `develop` so bumps flow
66
+ through the normal release cycle alongside feature work.
67
+ - **`-v` / `--verbose` flag on the `cgminer_api_client` CLI.** Logs the
68
+ JSON request and raw response to stderr, one line each, with a
69
+ `host:port` prefix so multi-miner fan-out output stays grep-able. The
70
+ formatted result still goes to stdout unchanged. Parsed via
71
+ `OptionParser#permute!`, so the flag works before or after the
72
+ command. Passwords in `addpool`, `setconfig`, `ascset`, and `pgaset`
73
+ are replaced with `[REDACTED]` in the log output (wire bytes are
74
+ unaffected).
75
+ - **`on_wire:` kwarg on `Miner#initialize` and `MinerPool#initialize`.**
76
+ Library-level hook used by the CLI's `-v` flag. Accepts a Proc of
77
+ shape `(direction, host, port, payload)` where direction is
78
+ `:request`, `:response`, or `:response_repaired`. Default `nil` is a
79
+ no-op; library code does not write to stderr itself.
80
+
81
+ ### Changed
82
+ - **`Miner::Commands::Privileged#access_denied?`** now raises
83
+ `CgminerApiClient::ApiError` with message `'access denied'`
84
+ instead of a bare `RuntimeError` with message `'access_denied'`.
85
+ Callers using `rescue CgminerApiClient::Error` or
86
+ `rescue StandardError` are unaffected; only code that specifically
87
+ pattern-matched on `RuntimeError` from a privileged command on an
88
+ unprivileged miner needs to update.
89
+ - **`MinerPool#load_miners!`** now raises `CgminerApiClient::Error`
90
+ (was `RuntimeError`) when `config/miners.yml` is missing. Existing
91
+ `rescue StandardError` clauses still work.
92
+ - Test-support code (FakeCgminer, CgminerFixtures) extracted to the
93
+ shared `cgminer_test_support` gem. Spec references now use
94
+ `CgminerTestSupport::FakeCgminer` and
95
+ `CgminerTestSupport::Fixtures::SUMMARY` etc. `script/fake_cgminer`
96
+ is now a thin shim that delegates to `bundle exec fake_cgminer`;
97
+ operator muscle memory unchanged.
98
+
99
+ ### Fixed
100
+ - **`MinerPool` no longer silently defaults a miners.yml entry
101
+ missing `host` to `CgminerApiClient.default_host`.** A typo'd
102
+ config entry like `{port: 4028}` used to quietly produce a Miner
103
+ pointing at `127.0.0.1`; now raises
104
+ `CgminerApiClient::Error "config/miners.yml: entry N is missing 'host'"`
105
+ on `MinerPool.new`.
106
+
8
107
  ## [0.3.0] - 2026-04-07
9
108
 
10
109
  ### Removed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/jramos/cgminer_api_client/actions/workflows/ci.yml/badge.svg)](https://github.com/jramos/cgminer_api_client/actions/workflows/ci.yml)
4
4
 
5
- A gem that allows sending API commands to a pool of [cgminer](https://github.com/ckolivas/cgminer) instances.
5
+ A gem that allows sending API commands to a pool of [cgminer](https://github.com/ckolivas/cgminer) instances. Ships as both a Ruby library and a CLI (`cgminer_api_client <command>`). Zero runtime dependencies beyond the Ruby standard library.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -131,12 +131,54 @@ distinct conditions, unlike in 0.2.x where they were conflated.
131
131
  pool.restart # PoolResult of per-miner outcomes
132
132
  ```
133
133
 
134
+ ### Errors
135
+
136
+ All gem-specific errors descend from `CgminerApiClient::Error < StandardError`:
137
+
138
+ - `CgminerApiClient::ConnectionError` — transport-level: the miner
139
+ was unreachable (DNS failure, connection refused, etc.).
140
+ - `CgminerApiClient::TimeoutError` — a `ConnectionError` subclass
141
+ for connect timeouts specifically.
142
+ - `CgminerApiClient::ApiError` — protocol-level: the miner answered
143
+ and returned a `STATUS=E`/`F` response.
144
+
145
+ `rescue CgminerApiClient::Error` catches everything gem-specific.
146
+ `rescue CgminerApiClient::ConnectionError` catches both generic
147
+ transport failures and connect timeouts. A `MinerPool` query never
148
+ raises per-miner errors — they land on the corresponding
149
+ `MinerResult.failure` inside the returned `PoolResult`.
150
+
134
151
  ## CLI Usage
135
152
 
136
153
  API commands can be sent to your miner pool from the command line.
137
154
 
138
155
  $ cgminer_api_client <command> (<arguments>)
139
156
 
157
+ ### Exit Codes and Streams
158
+
159
+ - exit `0` — at least one miner's command succeeded.
160
+ - exit `1` — every miner failed, or a top-level exception bubbled up.
161
+ - exit `64` — unknown command or missing command argument (`EX_USAGE`).
162
+
163
+ Per-miner responses are printed to stdout with a `host:port:` header.
164
+ Per-miner errors are printed to stderr as
165
+ `host:port: ErrorClass: message`. Set `DEBUG=1` to also print full
166
+ backtraces for any top-level exception:
167
+
168
+ $ DEBUG=1 cgminer_api_client summary
169
+
170
+ Pass `-v` / `--verbose` to log the JSON request and raw response to
171
+ stderr for wire-level debugging. Each line carries a `host:port`
172
+ prefix so fan-out across multiple miners stays grep-able:
173
+
174
+ $ cgminer_api_client -v summary
175
+ >>> 10.0.0.1:4028 {"command":"summary"}
176
+ <<< 10.0.0.1:4028 {"STATUS":[{"STATUS":"S",...}],"SUMMARY":[...]}
177
+
178
+ Password-bearing arguments to `addpool`, `setconfig`, `ascset`, and
179
+ `pgaset` are replaced with `[REDACTED]` in the log output (the real
180
+ value is still sent on the wire).
181
+
140
182
  ### Commands & Arguments
141
183
 
142
184
  #### Read-Only
@@ -201,6 +243,13 @@ The following privileged miner and pool commands are currently available:
201
243
 
202
244
  Any cgminer API commands not explictly defined above are implemented using `method_missing`. A complete list of available API commands and options can be found in the [cgminer API-README](https://github.com/ckolivas/cgminer/blob/master/API-README).
203
245
 
246
+ ## Further Reading
247
+
248
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history and the 0.2.x → 0.3.0 migration guide.
249
+ - [`AGENTS.md`](AGENTS.md) — context for AI coding assistants; also a useful conventions-and-extension guide for human contributors.
250
+ - [`docs/`](docs/) — topic-split deep dives on architecture, components, interfaces, data models, workflows, and dependencies. Start with [`docs/index.md`](docs/index.md).
251
+ - [cgminer API-README](https://github.com/ckolivas/cgminer/blob/master/API-README) — upstream documentation for the JSON API surface this gem wraps.
252
+
204
253
  ## Contributing
205
254
 
206
255
  1. Fork it ( <https://github.com/jramos/cgminer_api_client/fork> )
@@ -5,20 +5,62 @@ $LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib/")
5
5
 
6
6
  require 'cgminer_api_client'
7
7
  require 'pp'
8
+ require 'optparse'
9
+
10
+ # permute! (rather than order!) lets a flag appear after the command —
11
+ # `cgminer_api_client summary -v` works the same as `-v summary`.
12
+ verbose = false
13
+ begin
14
+ OptionParser.new do |opts|
15
+ opts.banner = 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
16
+ opts.on('-v', '--verbose', 'Log JSON request and raw response to stderr') do
17
+ verbose = true
18
+ end
19
+ end.permute!(ARGV)
20
+ rescue OptionParser::ParseError => e
21
+ warn "cgminer_api_client: #{e.message}"
22
+ warn 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
23
+ exit 64 # EX_USAGE
24
+ end
8
25
 
9
26
  command = ARGV.shift&.to_sym
10
27
  commands = CgminerApiClient::Miner::Commands.instance_methods
11
28
 
12
29
  unless command && commands.include?(command)
13
- warn 'USAGE: cgminer_api_client command (arguments)'
30
+ warn 'USAGE: cgminer_api_client [-v|--verbose] command (arguments)'
14
31
  warn "commands: #{commands.sort.join(', ')}"
15
32
  warn ''
16
33
  warn 'Set DEBUG=1 to see full backtraces on errors.'
17
34
  exit 64 # EX_USAGE
18
35
  end
19
36
 
37
+ # `MinerPool` fans out across miners in separate threads; Ruby's
38
+ # `warn` / `$stderr.write` is not atomic across threads for
39
+ # arbitrary-length payloads, so multi-miner fan-out can interleave
40
+ # mid-JSON without a mutex. The mutex also keeps a request/response
41
+ # pair contiguous. The host:port prefix lets operators grep a single
42
+ # miner out of the mixed stream.
43
+ on_wire = nil
44
+ if verbose
45
+ wire_mutex = Mutex.new
46
+ wire_prefix = {
47
+ request: '>>>',
48
+ response: '<<<',
49
+ response_repaired: '<<< (repaired)'
50
+ }.freeze
51
+ on_wire = lambda do |direction, host, port, payload|
52
+ wire_mutex.synchronize do
53
+ warn "#{wire_prefix[direction]} #{host}:#{port} #{payload}"
54
+ end
55
+ rescue Errno::EPIPE, IOError
56
+ # stderr was closed mid-run (piped through a command that exited, etc.).
57
+ # Best-effort logging must not take down the query fan-out.
58
+ nil
59
+ end
60
+ end
61
+
20
62
  begin
21
- pool = CgminerApiClient::MinerPool.new
63
+ pool = CgminerApiClient::MinerPool.new(on_wire: on_wire)
22
64
  result = ARGV.empty? ? pool.query(command) : pool.query(command, *ARGV)
23
65
 
24
66
  result.each do |r|
@@ -19,5 +19,76 @@ module CgminerApiClient
19
19
  # Raised when the miner returned a response whose STATUS field
20
20
  # indicates an error (cgminer status code 'E' or 'F'). The message
21
21
  # contains the cgminer code and message verbatim.
22
- class ApiError < Error; end
22
+ #
23
+ # Carries two structured fields for dispatch: callers `case e.code`
24
+ # instead of parsing English messages. The integer (#cgminer_code)
25
+ # is preserved verbatim from cgminer; the symbol (#code) is
26
+ # best-effort — cgminer's MSG enum names are stable but the
27
+ # integers occasionally shift between firmware versions, so add a
28
+ # row to CGMINER_CODES when you find a wire-observed integer worth
29
+ # dispatching on.
30
+ #
31
+ # Prefer #code for dispatch over #cgminer_code: paths that raise
32
+ # without a wire integer (the access_denied? local guard's call
33
+ # to #privileged hits the wire, but the rescue inside #privileged
34
+ # drops the integer) leave #cgminer_code nil while still setting
35
+ # #code consistently.
36
+ #
37
+ # Backward compatibility: `raise ApiError, "msg"` keeps working
38
+ # and #message is unchanged at every emission site.
39
+ class ApiError < Error
40
+ CGMINER_CODES = {
41
+ 14 => :invalid_command,
42
+ 45 => :access_denied
43
+ }.freeze
44
+
45
+ attr_reader :cgminer_code, :code
46
+
47
+ # Factory used at the wire-side emission point in Miner#check_status.
48
+ # Picks AccessDeniedError when the cgminer integer maps to
49
+ # :access_denied so callers can `rescue AccessDeniedError` for
50
+ # the most commonly dispatched-on case; falls back to ApiError
51
+ # for everything else. Wire boundary stays best-effort: a
52
+ # non-numeric Code coerces to nil and the symbolic tag becomes
53
+ # :unknown rather than raising mid-poll.
54
+ def self.for_status(status_code, message)
55
+ cgminer_code = Integer(status_code, exception: false)
56
+ klass = CGMINER_CODES[cgminer_code] == :access_denied ? AccessDeniedError : ApiError
57
+ klass.new("#{status_code}: #{message}", cgminer_code: cgminer_code)
58
+ end
59
+
60
+ def initialize(message = nil, cgminer_code: nil, code: nil)
61
+ # Fail loud at the library boundary on bad input. Without these
62
+ # guards, cgminer_code: "45" or 45.0 silently produces code:
63
+ # :unknown (CGMINER_CODES uses integer keys), and code: 42 raises
64
+ # NoMethodError on .to_sym deep in the constructor — both
65
+ # opaque failure modes. Wire-side callers that want best-effort
66
+ # Integer coercion go through ApiError.for_status.
67
+ unless cgminer_code.nil? || cgminer_code.is_a?(Integer)
68
+ raise ArgumentError,
69
+ "cgminer_code must be Integer or nil, got #{cgminer_code.class}: #{cgminer_code.inspect}"
70
+ end
71
+ unless code.nil? || code.is_a?(Symbol) || code.is_a?(String)
72
+ raise ArgumentError,
73
+ "code must be Symbol, String, or nil, got #{code.class}: #{code.inspect}"
74
+ end
75
+
76
+ super(message)
77
+ @cgminer_code = cgminer_code
78
+ @code = (code || CGMINER_CODES[cgminer_code] || :unknown).to_sym
79
+ end
80
+ end
81
+
82
+ # Specific subclass for cgminer's "access denied" response (STATUS=E
83
+ # Code 45) and the gem's own #access_denied? local guard. Inherits
84
+ # from ApiError so existing `rescue ApiError` clauses still catch
85
+ # it; callers wanting finer dispatch use `rescue AccessDeniedError`
86
+ # instead of `case e.code; when :access_denied`. Constructor pins
87
+ # code: :access_denied so the symbolic tag is consistent regardless
88
+ # of which call site raised.
89
+ class AccessDeniedError < ApiError
90
+ def initialize(message = nil, cgminer_code: nil)
91
+ super(message, cgminer_code: cgminer_code, code: :access_denied)
92
+ end
93
+ end
23
94
  end
@@ -186,7 +186,15 @@ module CgminerApiClient
186
186
  private
187
187
 
188
188
  def access_denied?
189
- raise 'access_denied' unless privileged
189
+ # privileged calls query(:privileged), so the wire IS hit; if
190
+ # the miner answers with STATUS=E Code 45, check_status raises
191
+ # AccessDeniedError and privileged rescues + returns false,
192
+ # dropping the cgminer integer in the rescue. Re-raise the
193
+ # specific subclass so callers can't tell which call path
194
+ # raised — `rescue AccessDeniedError` works identically for
195
+ # "real wire denied" and "guard-locally denied". cgminer_code
196
+ # stays nil here because it was discarded by privileged's rescue.
197
+ raise CgminerApiClient::AccessDeniedError, 'access denied' unless privileged
190
198
 
191
199
  false
192
200
  end
@@ -10,27 +10,29 @@ module CgminerApiClient
10
10
 
11
11
  attr_accessor :host, :port, :timeout
12
12
 
13
- def initialize(host = nil, port = nil, timeout = nil)
13
+ # Positional parameters at these indices carry user-controlled values
14
+ # (pool passwords, setconfig values, ascset/pgaset option values)
15
+ # that should not appear verbatim in wire-logs. The wire request is
16
+ # never modified; only the copy passed to the on_wire callback is
17
+ # redacted. If a new privileged command is added that accepts a
18
+ # secret positional arg, register its index here.
19
+ REDACTED_PARAM_INDEX = {
20
+ addpool: 2,
21
+ setconfig: 1,
22
+ ascset: 2,
23
+ pgaset: 2
24
+ }.freeze
25
+
26
+ def initialize(host = nil, port = nil, timeout = nil, on_wire: nil)
14
27
  @host = host || CgminerApiClient.default_host
15
28
  @port = port || CgminerApiClient.default_port
16
29
  @timeout = timeout || CgminerApiClient.default_timeout
30
+ @on_wire = on_wire
17
31
  end
18
32
 
19
33
  def query(method, *params)
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(',')
31
- end
32
-
33
- response = perform_request(request)
34
+ request, loggable_request = build_requests(method, params)
35
+ response = perform_request(request, loggable_request: loggable_request)
34
36
  data = sanitized(response)
35
37
  method.to_s.match?('\+') ? data : data[method.to_sym]
36
38
  end
@@ -60,19 +62,72 @@ module CgminerApiClient
60
62
 
61
63
  private
62
64
 
63
- def perform_request(request)
65
+ def build_requests(method, params)
66
+ return [{ command: method }, { command: method }] if params.empty?
67
+
68
+ escaped = params.map { |p| escape_param(p) }
69
+ loggable_escaped = redact_params(method, params).map { |p| escape_param(p) }
70
+
71
+ [
72
+ { command: method, parameter: escaped.join(',') },
73
+ { command: method, parameter: loggable_escaped.join(',') }
74
+ ]
75
+ end
76
+
77
+ # cgminer uses comma to separate parameters, so any literal commas in
78
+ # parameter values must be backslash-escaped, and any literal
79
+ # backslashes must themselves be doubled. The block form of gsub is
80
+ # used so the replacement string isn't interpreted (in gsub's
81
+ # replacement-string syntax, '\\' means a single literal backslash,
82
+ # which makes the obvious gsub('\\', '\\\\') a silent no-op).
83
+ def escape_param(param)
84
+ param.to_s.gsub('\\') { '\\\\' }.gsub(',') { '\\,' }
85
+ end
86
+
87
+ def redact_params(method, params)
88
+ idx = REDACTED_PARAM_INDEX[method.to_sym]
89
+ return params unless idx && params[idx]
90
+
91
+ redacted = params.dup
92
+ redacted[idx] = '[REDACTED]'
93
+ redacted
94
+ end
95
+
96
+ # on_wire is best-effort telemetry — a callback that raises must
97
+ # not break the real query path or leak the connection. Operators
98
+ # who suspect their callback is broken can remove -v to isolate.
99
+ def safe_on_wire(direction, payload)
100
+ return unless @on_wire
101
+
102
+ @on_wire.call(direction, @host, @port, payload)
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ def perform_request(request, loggable_request: request)
64
108
  begin
65
109
  s = open_socket(@host, @port, @timeout)
66
110
  rescue StandardError => e
67
111
  raise ConnectionError, "Connection to #{@host}:#{@port} failed: #{e.class}: #{e.message}"
68
112
  end
69
113
 
114
+ safe_on_wire(:request, loggable_request.to_json)
70
115
  s.write(request.to_json)
71
116
  response = s.read.strip.chars.map { |c| c.ord >= 32 ? c : format('\\u%04x', c.ord) }.join
72
117
  s.close
73
-
74
- response.gsub! '}{', '}, {'
75
- response.gsub! '[,{', '[ {'
118
+ safe_on_wire(:response, response)
119
+
120
+ # Legacy defensive repair for malformed multi-object responses. We
121
+ # haven't reproduced a case where this actually fires on modern
122
+ # cgminer; see spec/support/cgminer_fixtures.rb for commentary.
123
+ # Keep in place until we can confirm it isn't needed on real traffic.
124
+ # If the repair ever fires, emit an additional :response_repaired
125
+ # callback so a broken-looking JSON log isn't mysterious.
126
+ repaired = response.gsub('}{', '}, {').gsub('[,{', '[ {')
127
+ if repaired != response
128
+ safe_on_wire(:response_repaired, repaired)
129
+ response = repaired
130
+ end
76
131
 
77
132
  data = JSON.parse(response)
78
133
 
@@ -94,9 +149,12 @@ module CgminerApiClient
94
149
  msg = status['Msg']
95
150
 
96
151
  # 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).
152
+ # E=Error, F=Fatal. Errors and Fatals raise via ApiError.for_status
153
+ # which picks AccessDeniedError for Code 45 (so callers can
154
+ # `rescue AccessDeniedError`) and falls back to ApiError otherwise.
155
+ # The wire boundary stays best-effort — non-numeric Codes coerce
156
+ # to nil and the symbolic tag becomes :unknown rather than
157
+ # raising mid-poll.
100
158
  case sc
101
159
  when 'S'
102
160
  # no-op: success needs no notification
@@ -105,7 +163,7 @@ module CgminerApiClient
105
163
  when 'W'
106
164
  puts "Warning from API [#{c}]: #{msg}"
107
165
  else
108
- raise ApiError, "#{c}: #{msg}"
166
+ raise ApiError.for_status(c, msg)
109
167
  end
110
168
  end
111
169
 
@@ -6,7 +6,8 @@ module CgminerApiClient
6
6
 
7
7
  attr_accessor :miners
8
8
 
9
- def initialize
9
+ def initialize(on_wire: nil)
10
+ @on_wire = on_wire
10
11
  load_miners!
11
12
  end
12
13
 
@@ -99,14 +100,20 @@ module CgminerApiClient
99
100
  end
100
101
 
101
102
  def load_miners!
102
- raise 'Please create config/miners.yml' unless File.exist?('config/miners.yml')
103
+ raise CgminerApiClient::Error, 'Please create config/miners.yml' unless File.exist?('config/miners.yml')
103
104
 
104
105
  miners_config = YAML.safe_load_file('config/miners.yml')
105
- @miners = miners_config.collect do |miner|
106
+ @miners = miners_config.each_with_index.map do |entry, index|
107
+ unless entry.is_a?(Hash) && entry['host']
108
+ raise CgminerApiClient::Error,
109
+ "config/miners.yml: entry #{index} is missing 'host'"
110
+ end
111
+
106
112
  CgminerApiClient::Miner.new(
107
- miner['host'],
108
- miner['port'],
109
- miner['timeout']
113
+ entry['host'],
114
+ entry['port'],
115
+ entry['timeout'],
116
+ on_wire: @on_wire
110
117
  )
111
118
  end
112
119
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CgminerApiClient
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cgminer_api_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Ramos