cgminer_api_client 0.3.0 → 0.4.1

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: d045472d7098443d965f6bb948ad45aef2698f84e188f6b41769905dcccf6ce4
4
+ data.tar.gz: a3ac02b6c4b94d9a797a3fd501cb43f7f35ee421b4234af49b92a3fcc48c7832
5
5
  SHA512:
6
- metadata.gz: d7ebd6316421e95755768cf0f77dd672e3860c2fd508fd2314cc898f46b9c76d39efc78d3b1f4ac422c873871e1896b5fb8859e681c8ff3712f5f60f12c85351
7
- data.tar.gz: 99a4578907f13668e8f0bf5cf466298f40774e318d3f470ad38df7f0cfa2455b51c028866084a264201156b6e30505daf7849e1d6a12aec4122ec5092d788fed
6
+ metadata.gz: e75c9159055d5a5a0507b11e93f0f0a25512f83e2bebb0de9a9495aa7829b018b4c9a2da555d795f8a6d6372ea99b75ebc363bf84f5c9a3fd7d085a63e34b3d2
7
+ data.tar.gz: d112135f844f48772daac286dbf6f3d81224820a6cf91cc7039b9e56ef3164dc38e2357c0f48c9a4d484a40dd2cb9346f0ba8522a5094cbb027cf6356863d4b9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,114 @@ 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.1] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - `Miner` keeps parsing responses that repeat a key under json 3.0,
14
+ which rejects duplicate keys by default. The client now passes
15
+ `allow_duplicate_key: true`, preserving json 2.x's last-one-wins
16
+ behavior, so a miner whose firmware repeats a key no longer raises
17
+ `JSON::ParserError` mid-poll.
18
+
19
+ ## [0.4.0] - 2026-04-25
20
+
21
+ ### Added
22
+ - **`CgminerApiClient::AccessDeniedError`**, a subclass of `ApiError`
23
+ for the most commonly dispatched-on case (cgminer Code 45 + the
24
+ `access_denied?` local guard). Inherits from `ApiError` so existing
25
+ `rescue ApiError` clauses still catch it; callers wanting finer
26
+ dispatch use `rescue AccessDeniedError`. Constructor pins
27
+ `code: :access_denied` so the symbolic tag is consistent. The
28
+ wire-side `Miner#check_status` delegates to a new factory
29
+ `ApiError.for_status(c, msg)` that picks the right subclass
30
+ based on the integer Code.
31
+ - **Structured error codes on `CgminerApiClient::ApiError`.** Two new
32
+ reader methods alongside the existing `#message`:
33
+ `#cgminer_code` (the integer Code from cgminer's STATUS hash —
34
+ e.g., `45` for access denied — or `nil` if the wire integer was
35
+ discarded, as happens when the `access_denied?` local guard's
36
+ call to `privileged` hits the wire and the rescue inside
37
+ `privileged` drops the integer before re-raising) and `#code`
38
+ (a symbolic tag derived from the integer via
39
+ `ApiError::CGMINER_CODES`, falling back to `:unknown` for codes
40
+ not in the map). Callers can now `case e.code; when :access_denied`
41
+ instead of parsing English error strings. **Prefer `e.code` for
42
+ dispatch over `e.cgminer_code`** — `cgminer_code` can be nil even
43
+ when the symbolic tag is set. The map is intentionally
44
+ conservative — `14 → :invalid_command`, `45 → :access_denied`,
45
+ the codes observed against real cgminer wire fixtures.
46
+ Backward-compatible: `raise ApiError, "msg"` still works and
47
+ `e.message` is unchanged.
48
+
49
+ ### Changed
50
+ - `ApiError` constructor now validates `cgminer_code:` is `Integer`
51
+ or `nil` and `code:` is `Symbol`, `String`, or `nil`. Bad input
52
+ raises `ArgumentError` with a clear message instead of silently
53
+ producing `code: :unknown` (string `cgminer_code`) or
54
+ `NoMethodError` deep in the constructor (`code: 42`). Wire-side
55
+ callers go through `ApiError.for_status` which coerces a
56
+ non-numeric Code to `nil` so dispatch falls through to `:unknown`.
57
+ - **`docs/logging.md`** — short stub stating that `cgminer_api_client`
58
+ is intentionally silent: no `Logger` module, no structured log
59
+ events. The library raises on failure and returns result objects
60
+ on success; callers (`cgminer_monitor`, `cgminer_manager`, the
61
+ operator CLIs) own the log call sites. Points at
62
+ `cgminer_monitor/docs/log_schema.md` for the cross-repo schema
63
+ contract and names the events (`poll.miner_failed`,
64
+ `poll.unexpected_error`) that surface api_client exception classes.
65
+ - **`bundle-audit` in CI** (`.github/workflows/ci.yml`). New `audit`
66
+ job runs `bundle exec bundle-audit check --update` on every push
67
+ and PR, gating merges on known CVEs in `Gemfile.lock`. Advisory
68
+ DB is refreshed on each run from `rubysec/ruby-advisory-db`. Also
69
+ available locally as `bundle exec rake audit`.
70
+ - **Dependabot config** (`.github/dependabot.yml`). Weekly bump PRs
71
+ for Bundler and GitHub Actions, with `open-pull-requests-limit: 3`
72
+ per ecosystem. `versioning-strategy: lockfile-only` on bundler, so
73
+ Gemfile / gemspec constraints are never auto-widened — humans
74
+ widen `~>` bounds intentionally. Targets `develop` so bumps flow
75
+ through the normal release cycle alongside feature work.
76
+ - **`-v` / `--verbose` flag on the `cgminer_api_client` CLI.** Logs the
77
+ JSON request and raw response to stderr, one line each, with a
78
+ `host:port` prefix so multi-miner fan-out output stays grep-able. The
79
+ formatted result still goes to stdout unchanged. Parsed via
80
+ `OptionParser#permute!`, so the flag works before or after the
81
+ command. Passwords in `addpool`, `setconfig`, `ascset`, and `pgaset`
82
+ are replaced with `[REDACTED]` in the log output (wire bytes are
83
+ unaffected).
84
+ - **`on_wire:` kwarg on `Miner#initialize` and `MinerPool#initialize`.**
85
+ Library-level hook used by the CLI's `-v` flag. Accepts a Proc of
86
+ shape `(direction, host, port, payload)` where direction is
87
+ `:request`, `:response`, or `:response_repaired`. Default `nil` is a
88
+ no-op; library code does not write to stderr itself.
89
+
90
+ ### Changed
91
+ - **`Miner::Commands::Privileged#access_denied?`** now raises
92
+ `CgminerApiClient::ApiError` with message `'access denied'`
93
+ instead of a bare `RuntimeError` with message `'access_denied'`.
94
+ Callers using `rescue CgminerApiClient::Error` or
95
+ `rescue StandardError` are unaffected; only code that specifically
96
+ pattern-matched on `RuntimeError` from a privileged command on an
97
+ unprivileged miner needs to update.
98
+ - **`MinerPool#load_miners!`** now raises `CgminerApiClient::Error`
99
+ (was `RuntimeError`) when `config/miners.yml` is missing. Existing
100
+ `rescue StandardError` clauses still work.
101
+ - Test-support code (FakeCgminer, CgminerFixtures) extracted to the
102
+ shared `cgminer_test_support` gem. Spec references now use
103
+ `CgminerTestSupport::FakeCgminer` and
104
+ `CgminerTestSupport::Fixtures::SUMMARY` etc. `script/fake_cgminer`
105
+ is now a thin shim that delegates to `bundle exec fake_cgminer`;
106
+ operator muscle memory unchanged.
107
+
108
+ ### Fixed
109
+ - **`MinerPool` no longer silently defaults a miners.yml entry
110
+ missing `host` to `CgminerApiClient.default_host`.** A typo'd
111
+ config entry like `{port: 4028}` used to quietly produce a Miner
112
+ pointing at `127.0.0.1`; now raises
113
+ `CgminerApiClient::Error "config/miners.yml: entry N is missing 'host'"`
114
+ on `MinerPool.new`.
115
+
8
116
  ## [0.3.0] - 2026-04-07
9
117
 
10
118
  ### 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,21 +62,77 @@ 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
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
73
131
 
74
- response.gsub! '}{', '}, {'
75
- response.gsub! '[,{', '[ {'
76
-
77
- data = JSON.parse(response)
132
+ # json 3.0 rejects duplicate keys by default; keep json 2.x's
133
+ # last-one-wins behavior since some cgminer forks repeat keys.
134
+ # Older json versions ignore the option.
135
+ data = JSON.parse(response, allow_duplicate_key: true)
78
136
 
79
137
  if request[:command].to_s.match?('\+')
80
138
  data.each_pair do |_command, response|
@@ -94,9 +152,12 @@ module CgminerApiClient
94
152
  msg = status['Msg']
95
153
 
96
154
  # 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).
155
+ # E=Error, F=Fatal. Errors and Fatals raise via ApiError.for_status
156
+ # which picks AccessDeniedError for Code 45 (so callers can
157
+ # `rescue AccessDeniedError`) and falls back to ApiError otherwise.
158
+ # The wire boundary stays best-effort — non-numeric Codes coerce
159
+ # to nil and the symbolic tag becomes :unknown rather than
160
+ # raising mid-poll.
100
161
  case sc
101
162
  when 'S'
102
163
  # no-op: success needs no notification
@@ -105,7 +166,7 @@ module CgminerApiClient
105
166
  when 'W'
106
167
  puts "Warning from API [#{c}]: #{msg}"
107
168
  else
108
- raise ApiError, "#{c}: #{msg}"
169
+ raise ApiError.for_status(c, msg)
109
170
  end
110
171
  end
111
172
 
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Ramos