diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16f2d6e0a8..c82d5eb20f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,6 @@ The pre-push hooks also run repository checks before a branch can be pushed: - `client`: TypeScript typechecking via `npm exec --no -- tsc --noEmit` - `server`: `golangci-lint run -c .golangci.yaml` - `plugin/proto`: `golangci-lint run -c .golangci.yaml` -- `plugin/antminer`: `golangci-lint run -c .golangci.yaml` ## Git Workflow diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts index 0fda26625a..c7638a633d 100644 --- a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts @@ -19,7 +19,7 @@ export const miners: MinerStateSnapshot[] = [ model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-01", - driverName: "antminer", + driverName: "asicrs", hashrate: [ { timestamp: { seconds: BigInt(1641024000), nanos: 0 }, @@ -76,7 +76,7 @@ export const miners: MinerStateSnapshot[] = [ model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-02", - driverName: "antminer", + driverName: "asicrs", hashrate: [ { timestamp: { seconds: BigInt(1641024000), nanos: 0 }, @@ -133,7 +133,7 @@ export const miners: MinerStateSnapshot[] = [ model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-03", - driverName: "antminer", + driverName: "asicrs", hashrate: [ { timestamp: { seconds: BigInt(1641024000), nanos: 0 }, @@ -190,7 +190,7 @@ export const miners: MinerStateSnapshot[] = [ model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-04", - driverName: "antminer", + driverName: "asicrs", hashrate: [ { timestamp: { seconds: BigInt(1641024000), nanos: 0 }, diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts index af5198cccf..d1517db3b6 100644 --- a/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts @@ -96,7 +96,7 @@ export const hashingMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ONLINE, temperatureStatus: TemperatureStatus.OK, @@ -116,7 +116,7 @@ export const offlineMiner: MinerStateSnapshot = { model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-offline", - driverName: "antminer", + driverName: "asicrs", hashrate: [], efficiency: [], powerUsage: [], @@ -143,7 +143,7 @@ export const sleepingMiner: MinerStateSnapshot = { model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-sleeping", - driverName: "antminer", + driverName: "asicrs", hashrate: [ { timestamp: { seconds: BigInt(1641283200), nanos: 0 }, @@ -179,7 +179,7 @@ export const authRequiredMiner: MinerStateSnapshot = { model: "S19 Pro", manufacturer: "Bitmain", workerName: "worker-auth", - driverName: "antminer", + driverName: "asicrs", hashrate: [], efficiency: [], powerUsage: [], @@ -205,7 +205,7 @@ export const poolRequiredMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.NEEDS_MINING_POOL, temperatureStatus: TemperatureStatus.OK, @@ -224,7 +224,7 @@ export const controlBoardFailureMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, @@ -243,7 +243,7 @@ export const hashboardFailureMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, @@ -262,7 +262,7 @@ export const psuFailureMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, @@ -281,7 +281,7 @@ export const fanFailureMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, @@ -304,7 +304,7 @@ export const multipleHashboardFailuresMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, @@ -323,7 +323,7 @@ export const multipleComponentFailuresMiner: MinerStateSnapshot = { pairingStatus: PairingStatus.PAIRED, model: "S19 Pro", manufacturer: "Bitmain", - driverName: "antminer", + driverName: "asicrs", ...baseMeasurements, deviceStatus: DeviceStatus.ERROR, temperatureStatus: TemperatureStatus.OK, diff --git a/deployment-files/install.sh b/deployment-files/install.sh index bda83c0748..c408adac04 100755 --- a/deployment-files/install.sh +++ b/deployment-files/install.sh @@ -425,7 +425,7 @@ extract_and_cd "/tmp/${TAR_NAME}" "$INSTALL_DIR" # Validate plugin binaries exist echo "🔌 Validating plugin binaries..." PLUGIN_DIR="server" -REQUIRED_PLUGINS=("proto-plugin" "antminer-plugin" "asicrs-plugin") +REQUIRED_PLUGINS=("proto-plugin" "asicrs-plugin") MISSING_PLUGINS=() for plugin in "${REQUIRED_PLUGINS[@]}"; do diff --git a/deployment-files/server/Dockerfile b/deployment-files/server/Dockerfile index 7ab212eb21..2dc5a1afb5 100644 --- a/deployment-files/server/Dockerfile +++ b/deployment-files/server/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app COPY fleetd /app/ RUN mkdir -p /app/plugins -COPY proto-plugin antminer-plugin asicrs-plugin asicrs-config.yaml /app/plugins/ +COPY proto-plugin asicrs-plugin asicrs-config.yaml /app/plugins/ RUN chmod 755 /app/fleetd /app/plugins/*-plugin diff --git a/deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PluginValidator.cs b/deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PluginValidator.cs index fc4f5082c0..4e3c48c7f5 100644 --- a/deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PluginValidator.cs +++ b/deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PluginValidator.cs @@ -7,7 +7,7 @@ public sealed class PluginValidator : IPluginValidator private static readonly string[] RequiredPlugins = [ "proto-plugin", - "antminer-plugin" + "asicrs-plugin" ]; private readonly WslCommandExecutor _executor; diff --git a/docs/architecture.md b/docs/architecture.md index bb4214ef84..66787c7edb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,9 +23,8 @@ The backend is a Go service that handles device pairing, telemetry collection, c Proto Fleet supports multiple miner integrations through plugins: - `plugin/proto/` for Proto miners -- `plugin/antminer/` for Antminer devices - `plugin/virtual/` for the virtual miner simulator used in development and testing -- `plugin/asicrs/` for Rust-based multi-manufacturer ASIC miner support +- `plugin/asicrs/` for Rust-based multi-manufacturer ASIC miner support, including stock and aftermarket Antminers - `plugin/example-python/` for the example Python plugin (template for plugin authors) Shared RPC and message contracts live in `proto/`. Miner-hosted ProtoOS API definitions live in `proto-rig-api/`. diff --git a/go.work b/go.work index cdfbc96d2c..5c2a4d5d69 100644 --- a/go.work +++ b/go.work @@ -1,7 +1,6 @@ go 1.25.4 use ( - ./plugin/antminer ./plugin/proto ./plugin/virtual ./server diff --git a/justfile b/justfile index 5d06748e85..9f23d92a59 100644 --- a/justfile +++ b/justfile @@ -33,14 +33,14 @@ build-plugins-docker: (_build-go-plugins-cross "linux" "arm64" "server/plugins") # build plugin binaries for multiple architectures (deployment) build-plugins-release: _build-go-plugins-multi-arch _asicrs-build-release -# rebuild a specific plugin for the Docker runtime (linux/arm64): proto, antminer, virtual, or asicrs +# rebuild a specific plugin for the Docker runtime (linux/arm64): proto, virtual, or asicrs rebuild-plugin name: #!/usr/bin/env bash set -euo pipefail case "{{name}}" in - proto|antminer|virtual|asicrs) ;; + proto|virtual|asicrs) ;; *) - echo "Unknown plugin: {{name}}. Valid: proto, antminer, virtual, asicrs" >&2 + echo "Unknown plugin: {{name}}. Valid: proto, virtual, asicrs" >&2 exit 1 ;; esac @@ -48,7 +48,7 @@ rebuild-plugin name: # present and built for linux/arm64 before force-rebuilding the named one. just build-plugins-docker case "{{name}}" in - proto|antminer) + proto) (cd plugin/{{name}} && GOOS=linux GOARCH=arm64 go build -o ../../server/plugins/{{name}}-plugin .) chmod +x server/plugins/{{name}}-plugin ;; @@ -70,7 +70,7 @@ build-virtual-plugin: (rebuild-plugin "virtual") # --- Tests --- # run plugin contract tests (each test suite in its own container for port isolation) -test-contract: _asicrs-build +test-contract: _asicrs-build-contract #!/usr/bin/env bash set -euo pipefail GO_VERSION=$(grep '^go ' tests/plugin-contract/go.mod | awk '{print $2}') @@ -100,7 +100,6 @@ test-contract: _asicrs-build "$IMAGE" sh -c ' mkdir -p server/plugins && \ (cd plugin/proto && go build -o ../../server/plugins/proto-plugin .) && \ - (cd plugin/antminer && go build -o ../../server/plugins/antminer-plugin .) && \ (cd tests/plugin-contract && go test -c -o bin/miners.test ./miners/) ' @@ -179,8 +178,6 @@ update-go-deps: (cd server && go get -u -t ./... && go mod tidy) echo "Updating plugin/proto dependencies..." (cd plugin/proto && go get -u -t ./... && go mod tidy) - echo "Updating plugin/antminer dependencies..." - (cd plugin/antminer && go get -u -t ./... && go mod tidy) echo "Updating plugin/virtual dependencies..." (cd plugin/virtual && go get -u -t ./... && go mod tidy) echo "Updating server/fake-proto-rig dependencies..." @@ -258,7 +255,6 @@ _lint-plugins: #!/usr/bin/env bash set -euo pipefail (cd plugin/proto && golangci-lint run -c .golangci.yaml) - (cd plugin/antminer && golangci-lint run -c .golangci.yaml) [working-directory: 'server'] _format-server: @@ -272,7 +268,6 @@ _format-plugins: #!/usr/bin/env bash set -euo pipefail (cd plugin/proto && goimports -w .) - (cd plugin/antminer && goimports -w .) _gen-protos: PATH="$(pwd)/client/node_modules/.bin:$PATH" buf generate @@ -307,46 +302,40 @@ _build-go-plugins-native outdir: _go-work-sync #!/usr/bin/env bash set -euo pipefail # Plugins import from ../../server, so server module files also affect the graph. - SOURCES="plugin/proto plugin/antminer server/sdk/v1 go.work go.work.sum server/go.mod server/go.sum plugin/proto/go.mod plugin/proto/go.sum plugin/antminer/go.mod plugin/antminer/go.sum" + SOURCES="plugin/proto server/sdk/v1 go.work go.work.sum server/go.mod server/go.sum plugin/proto/go.mod plugin/proto/go.sum" PROTO_BIN={{outdir}}/proto-plugin - ANT_BIN={{outdir}}/antminer-plugin PLATFORM_MARKER={{outdir}}/.go-plugins-platform WANT_PLATFORM="native" - if [ -f "$PROTO_BIN" ] && [ -f "$ANT_BIN" ] \ + if [ -f "$PROTO_BIN" ] \ && [ -f "$PLATFORM_MARKER" ] && [ "$(cat "$PLATFORM_MARKER")" = "$WANT_PLATFORM" ] \ - && [ -z "$(find $SOURCES -newer "$PROTO_BIN" -type f 2>/dev/null | head -1)" ] \ - && [ -z "$(find $SOURCES -newer "$ANT_BIN" -type f 2>/dev/null | head -1)" ]; then + && [ -z "$(find $SOURCES -newer "$PROTO_BIN" -type f 2>/dev/null | head -1)" ]; then echo "Go plugins up to date, skipping build." exit 0 fi echo "Building Go plugins..." mkdir -p {{outdir}} (cd plugin/proto && go build -o ../../{{outdir}}/proto-plugin .) - (cd plugin/antminer && go build -o ../../{{outdir}}/antminer-plugin .) - chmod +x {{outdir}}/proto-plugin {{outdir}}/antminer-plugin + chmod +x {{outdir}}/proto-plugin echo "$WANT_PLATFORM" > "$PLATFORM_MARKER" _build-go-plugins-cross goos goarch outdir: _go-work-sync #!/usr/bin/env bash set -euo pipefail # Plugins import from ../../server, so server module files also affect the graph. - SOURCES="plugin/proto plugin/antminer server/sdk/v1 go.work go.work.sum server/go.mod server/go.sum plugin/proto/go.mod plugin/proto/go.sum plugin/antminer/go.mod plugin/antminer/go.sum" + SOURCES="plugin/proto server/sdk/v1 go.work go.work.sum server/go.mod server/go.sum plugin/proto/go.mod plugin/proto/go.sum" PROTO_BIN={{outdir}}/proto-plugin - ANT_BIN={{outdir}}/antminer-plugin PLATFORM_MARKER={{outdir}}/.go-plugins-platform WANT_PLATFORM="{{goos}}/{{goarch}}" - if [ -f "$PROTO_BIN" ] && [ -f "$ANT_BIN" ] \ + if [ -f "$PROTO_BIN" ] \ && [ -f "$PLATFORM_MARKER" ] && [ "$(cat "$PLATFORM_MARKER")" = "$WANT_PLATFORM" ] \ - && [ -z "$(find $SOURCES -newer "$PROTO_BIN" -type f 2>/dev/null | head -1)" ] \ - && [ -z "$(find $SOURCES -newer "$ANT_BIN" -type f 2>/dev/null | head -1)" ]; then + && [ -z "$(find $SOURCES -newer "$PROTO_BIN" -type f 2>/dev/null | head -1)" ]; then echo "Go plugins up to date for {{goos}}/{{goarch}}, skipping build." exit 0 fi echo "Building Go plugins for {{goos}}/{{goarch}}..." mkdir -p {{outdir}} (cd plugin/proto && GOOS={{goos}} GOARCH={{goarch}} go build -o ../../{{outdir}}/proto-plugin .) - (cd plugin/antminer && GOOS={{goos}} GOARCH={{goarch}} go build -o ../../{{outdir}}/antminer-plugin .) - chmod +x {{outdir}}/proto-plugin {{outdir}}/antminer-plugin + chmod +x {{outdir}}/proto-plugin echo "$WANT_PLATFORM" > "$PLATFORM_MARKER" _build-go-plugins-multi-arch: _go-work-sync @@ -355,9 +344,7 @@ _build-go-plugins-multi-arch: _go-work-sync echo "Building Go plugins for multiple architectures..." mkdir -p deployment-files/server (cd plugin/proto && GOOS=linux GOARCH=amd64 go build -o ../../deployment-files/server/proto-plugin-amd64 .) - (cd plugin/antminer && GOOS=linux GOARCH=amd64 go build -o ../../deployment-files/server/antminer-plugin-amd64 .) (cd plugin/proto && GOOS=linux GOARCH=arm64 go build -o ../../deployment-files/server/proto-plugin-arm64 .) - (cd plugin/antminer && GOOS=linux GOARCH=arm64 go build -o ../../deployment-files/server/antminer-plugin-arm64 .) chmod +x deployment-files/server/*-plugin-* _asicrs-build outdir="server/plugins": @@ -406,6 +393,29 @@ _asicrs-build outdir="server/plugins": touch "$BIN" echo "$WANT_PLATFORM" > "$PLATFORM_MARKER" +_asicrs-build-contract: + #!/usr/bin/env bash + set -euo pipefail + BIN=server/plugins/asicrs-plugin + PLATFORM_MARKER=server/plugins/.asicrs-platform + WANT_PLATFORM="contract-docker-default" + if [ -f "$BIN" ] \ + && [ -f "$PLATFORM_MARKER" ] && [ "$(cat "$PLATFORM_MARKER")" = "$WANT_PLATFORM" ] \ + && [ -z "$(find plugin/asicrs sdk/rust server/sdk/v1/pb -newer "$BIN" -type f 2>/dev/null | head -1)" ]; then + echo "asicrs plugin up to date for contract tests, skipping build." + exit 0 + fi + echo "Building asicrs plugin for contract tests using Docker's default platform..." + mkdir -p server/plugins + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output type=local,dest=server/plugins \ + . + chmod +x "$BIN" + # buildx --output type=local preserves the in-image mtime; touch so freshness checks see "now". + touch "$BIN" + echo "$WANT_PLATFORM" > "$PLATFORM_MARKER" + _asicrs-build-docker: #!/usr/bin/env bash set -euo pipefail diff --git a/lefthook.yml b/lefthook.yml index 0c3b397e4a..ac98f8f99c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -60,7 +60,3 @@ pre-push: plugin-proto-lint: root: plugin/proto run: golangci-lint run -c .golangci.yaml - - plugin-antminer-lint: - root: plugin/antminer - run: golangci-lint run -c .golangci.yaml diff --git a/plugin/antminer/.golangci.yaml b/plugin/antminer/.golangci.yaml deleted file mode 100644 index 19f65c3221..0000000000 --- a/plugin/antminer/.golangci.yaml +++ /dev/null @@ -1,132 +0,0 @@ -version: "2" -run: - go: "1.24" - modules-download-mode: readonly - tests: true - allow-parallel-runners: true - allow-serial-runners: true -linters: - default: all - disable: - - contextcheck - - cyclop - - depguard - - dogsled - - dupl - - embeddedstructfieldcheck - - err113 - - errname - - errorlint - - exhaustruct - - forbidigo - - funcorder - - funlen - - gochecknoglobals - - gochecknoinits - - gocognit - - gocyclo - - godot - - godox - - gomoddirectives - - ireturn - - lll - - maintidx - - mnd - - modernize - - musttag - - nestif - - nilnil - - noinlineerr - - nlreturn - - noctx - - nolintlint - - nonamedreturns - - nosprintfhostport - - paralleltest - - perfsprint - - prealloc - - protogetter - - revive - - staticcheck - - tagalign - - tagliatelle - - testifylint - - testpackage - - thelper - - unused - - varnamelen - - whitespace - - wsl - - wsl_v5 - settings: - dupl: - threshold: 100 - forbidigo: - forbid: - - pattern: fmt\.Errorf - goconst: - min-len: 8 - min-occurrences: 3 - gocritic: - disabled-checks: - - ifElseChain - gocyclo: - min-complexity: 20 - wrapcheck: - ignore-package-globs: - - github.com/block/proto-fleet/* - exclusions: - generated: lax - rules: - - linters: - - goconst - - scopelint - path: _test\.go - - path: apps/hamilton/(.*)/fake_(.*)\.go - text: parameter '.+' seems to be unused, consider removing or renaming it as _ - - path: (.+)\.go$ - text: '^(G104|G204|G307|G306|G404):' - - path: (.+)\.go$ - text: Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*Unlock|.*Print(f|ln|)|os\.(Un)?Setenv|.*\.Rollback). is not checked - - path: (.+)\.go$ - text: 'internal error: no range for' - - path: (.+)\.go$ - text: exported method `.*\.(MarshalJSON|UnmarshalJSON|URN|Payload|GoString|Close|Provides|Requires|ExcludeFromHash|MarshalText|UnmarshalText|Description|Check|Poll|Severity)` should have comment or be unexported - - path: (.+)\.go$ - text: composite literal uses unkeyed fields - - path: (.+)\.go$ - text: declaration of "err" shadows declaration - - path: (.+)\.go$ - text: by other packages, and that stutters - - path: (.+)\.go$ - text: Potential file inclusion via variable - - path: (.+)\.go$ - text: at least one file in a package should have a package comment - - path: (.+)\.go$ - text: 'package-comments: should have a package comment' - - path: (.+)\.go$ - text: Use errors.As to check for specific errors - - path: (.+)\.go$ - text: should have comment or be unexported - - path: (.+)\.go$ - text: '`ctx` is unused' - - path: (.+)\.go$ - text: parameter 'ctx' seems to be unused - paths: - - generated - - third_party$ - - builtin$ - - examples$ -issues: - max-issues-per-linter: 0 - max-same-issues: 0 -formatters: - enable: - - gofmt - exclusions: - generated: lax - paths: - - generated - - third_party$ - - builtin$ - - examples$ diff --git a/plugin/antminer/README.md b/plugin/antminer/README.md deleted file mode 100644 index 7c2a1e8aa6..0000000000 --- a/plugin/antminer/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Antminer Plugin - -Fleet SDK v1 plugin for Bitmain Antminer devices. - -## Features - -### Supported Operations -- Device discovery via RPC port 4028 -- Username/password authentication -- Status monitoring via RPC API -- Telemetry collection (hashrate, temperature, power, uptime) -- Web interface access - -### Limitations -- Mining control requires web API implementation -- Firmware updates not implemented -- Pool configuration requires web API implementation -- No streaming support -- No batch operations - -## Architecture - -``` -plugin/antminer/ -├── main.go # Plugin entry point -├── internal/ -│ ├── types/ # Shared types -│ ├── driver/ # Driver implementation -│ └── device/ # Device implementation -├── pkg/ -│ ├── antminer/ # Client and RPC implementation -│ │ └── web/ # Web API types -│ └── auth/ # Authentication service -└── tests/ # Integration tests -``` - -## Usage - -### Build -```bash -go build -o antminer . -``` - -### Credentials -Plugin requires `sdk.UsernamePassword` credentials for device pairing. - -### Discovery -Connects to port 4028 and issues RPC `version` command to identify Antminer devices. - -## RPC Commands -- `version` - Device information -- `summary` - Mining statistics -- `devs` - ASIC device data -- `pools` - Pool configuration - -## Status Mapping -| Antminer State | SDK Health | Description | -|----------------|------------|-------------| -| Mining + hashrate | HealthyActive | Normal operation | -| Mining + no hashrate | Warning | Performance issue | -| Hardware errors | Warning | Hardware problems | -| RPC error | Critical | Communication failure | - -## Testing -```bash -# Unit tests -go test ./internal/... ./pkg/... - -# Integration tests -go test ./tests/... -``` diff --git a/plugin/antminer/go.mod b/plugin/antminer/go.mod deleted file mode 100644 index 8f28804d48..0000000000 --- a/plugin/antminer/go.mod +++ /dev/null @@ -1,36 +0,0 @@ -module github.com/block/proto-fleet/plugin/antminer - -go 1.25.4 - -require ( - github.com/block/proto-fleet/server v0.0.0-20260612045813-75933fce45c2 - github.com/hashicorp/go-plugin v1.8.0 - github.com/stretchr/testify v1.11.1 - go.uber.org/mock v0.6.0 - golang.org/x/sync v0.21.0 -) - -replace github.com/block/proto-fleet/server => ../../server - -require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/yamux v0.1.2 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/oklog/run v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect - google.golang.org/grpc v1.81.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/plugin/antminer/go.sum b/plugin/antminer/go.sum deleted file mode 100644 index 8cc6bfb7b3..0000000000 --- a/plugin/antminer/go.sum +++ /dev/null @@ -1,97 +0,0 @@ -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= -github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= -github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= -github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= -github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= -github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= -github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= -github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugin/antminer/internal/device/device.go b/plugin/antminer/internal/device/device.go deleted file mode 100644 index 4336540624..0000000000 --- a/plugin/antminer/internal/device/device.go +++ /dev/null @@ -1,768 +0,0 @@ -// Package device implements the Fleet SDK Device interface for individual Antminer devices. -package device - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - "golang.org/x/sync/errgroup" - - "github.com/block/proto-fleet/plugin/antminer/internal/types" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdk "github.com/block/proto-fleet/server/sdk/v1" -) - -const ( - rpcPort = 4028 // Default RPC port for Antminers - newDeviceTimeout = 10 * time.Second // Timeout for new device creation - blinkLEDDuration = 30 * time.Second // Duration to blink LED for identification - firmwareRefreshInterval = 5 * time.Minute - - // Sensor metric constants - sensorTypeUptime = "uptime" - unitSeconds = "seconds" -) - -var _ sdk.Device = (*Device)(nil) - -// toMetricValue wraps a numeric value in a MetricValue struct with Gauge kind. -func toMetricValue(value float64) *sdk.MetricValue { - return &sdk.MetricValue{ - Value: value, - Kind: sdk.MetricKindGauge, - } -} - -// toMetricValueWithKind wraps a numeric value in a MetricValue struct with the specified kind. -func toMetricValueWithKind(value float64, kind sdk.MetricKind) *sdk.MetricValue { - return &sdk.MetricValue{ - Value: value, - Kind: kind, - } -} - -// setMetricIfNotNil sets a gauge metric value if the source pointer is not nil. -func setMetricIfNotNil(source *float64) *sdk.MetricValue { - if source != nil { - return toMetricValue(*source) - } - return nil -} - -// ptrString returns a pointer to a string value. -func ptrString(s string) *string { - return &s -} - -func isSleepMode(config *web.MinerConfig) bool { - if config == nil { - return false - } - - workMode := config.BitmainWorkMode - if config.MinerMode != "" { - workMode = web.BitmainWorkMode(config.MinerMode) - } - - return workMode == web.BitmainWorkModeSleep -} - -// Device implements the SDK Device interface for a single Antminer. -type Device struct { - // Identity and connection information - id string - deviceInfo sdk.DeviceInfo - - // Authentication - store the SDK type for security and type safety - credentials sdk.UsernamePassword - - // Communication and authentication - client antminer.AntminerClient - - // Status caching to reduce RPC calls - lastStatus *sdk.DeviceMetrics - lastStatusAt time.Time - statusMutex sync.Mutex - statusTTL time.Duration - - lastFirmwareCheckAt time.Time - - curtailmentMutex sync.Mutex - preFullCurtailMiningState fullCurtailMiningState -} - -type fullCurtailMiningState int - -const ( - fullCurtailMiningStateUnknown fullCurtailMiningState = iota - fullCurtailMiningStateWasMining - fullCurtailMiningStateWasNotMining -) - -func miningStateBeforeFullCurtail(wasMining bool) fullCurtailMiningState { - if wasMining { - return fullCurtailMiningStateWasMining - } - return fullCurtailMiningStateWasNotMining -} - -func (s fullCurtailMiningState) restoreMiningDecision() (bool, bool) { - switch s { - case fullCurtailMiningStateUnknown: - return false, false - case fullCurtailMiningStateWasMining: - return true, true - case fullCurtailMiningStateWasNotMining: - return false, true - default: - return false, false - } -} - -// New creates a new Antminer device instance. -func New(deviceID string, deviceInfo sdk.DeviceInfo, credentials sdk.UsernamePassword, clientFactory types.ClientFactory) (*Device, error) { - device := &Device{ - id: deviceID, - deviceInfo: deviceInfo, - credentials: credentials, - statusTTL: types.StatusCacheTTL(), - } - - // If firmware version is already known from pairing, start the refresh - // throttle from now so we don't immediately re-fetch what we already have. - if deviceInfo.FirmwareVersion != "" { - device.lastFirmwareCheckAt = time.Now() - } - - client, err := clientFactory(deviceInfo.Host, rpcPort, types.WebPort(), deviceInfo.URLScheme) - if err != nil { - return nil, fmt.Errorf("failed to create client: %w", err) - } - - device.client = client - - if credentials.Username != "" && credentials.Password != "" { - if err := client.SetCredentials(credentials); err != nil { - slog.Warn("Failed to set credentials", "deviceID", deviceID, "username", credentials.Username, "error", err) - } - } - - slog.Debug("Antminer device instance created successfully", "deviceID", deviceID, "username", credentials.Username) - return device, nil -} - -func (d *Device) Connect(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, newDeviceTimeout) - defer cancel() - - if _, err := d.Status(ctx); err != nil { - d.client.Close() - return fmt.Errorf("failed to verify device communication: %w", err) - } - return nil -} - -// ID implements the SDK Device interface. -func (d *Device) ID() string { - return d.id -} - -// DescribeDevice implements the SDK Device interface. -func (d *Device) DescribeDevice(ctx context.Context) (sdk.DeviceInfo, sdk.Capabilities, error) { - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - - capabilities := sdk.Capabilities{ - // Core capabilities - sdk.CapabilityPollingHost: true, // This device supports RPC status polling - - // Command capabilities - based on Antminer capabilities - sdk.CapabilityReboot: true, // We can reboot devices - sdk.CapabilityMiningStart: true, // Supported via bitmain-work-mode = "0" - sdk.CapabilityMiningStop: true, // Supported via bitmain-work-mode = "1" (sleep) - sdk.CapabilityCurtailFull: true, // FULL curtailment uses mining start/stop. - sdk.CapabilityLEDBlink: true, // We can blink LED for identification - sdk.CapabilityFactoryReset: false, // Factory reset not supported - sdk.CapabilityCoolingModeAir: false, // Air cooling mode not configurable - sdk.CapabilityCoolingModeImmerse: false, // Immersion cooling mode not supported - sdk.CapabilityPoolConfig: true, // We can configure mining pools - sdk.CapabilityPoolPriority: true, // We can set pool priority - sdk.CapabilityLogsDownload: true, // We can download logs - sdk.CapabilityUpdateMinerPassword: true, // We can update web UI password - - // Telemetry capabilities - sdk.CapabilityRealtimeTelemetry: true, // We support real-time telemetry - sdk.CapabilityHistoricalData: false, // Historical data not supported - sdk.CapabilityHashrateReported: true, // We report hashrate - sdk.CapabilityPowerUsage: true, // We report power usage - sdk.CapabilityTemperature: true, // We report temperature - sdk.CapabilityFanSpeed: true, // We report fan speed - sdk.CapabilityEfficiency: true, // We report efficiency - sdk.CapabilityUptime: true, // We report uptime - sdk.CapabilityErrorCount: true, // We report error count - sdk.CapabilityMinerStatus: true, // We report miner status - sdk.CapabilityPoolStats: true, // We report pool stats - sdk.CapabilityPerChipStats: true, // We report per-chip stats - sdk.CapabilityPerBoardStats: true, // We report per-board stats - sdk.CapabilityPSUStats: false, // PSU stats not supported - - // Firmware capabilities - sdk.CapabilityFirmware: true, // We support firmware operations - sdk.CapabilityOTAUpdate: false, // OTA update not supported - sdk.CapabilityManualUpload: true, // We support manual firmware upload - - // Authentication capabilities - sdk.CapabilityBasicAuth: true, // We use basic (username/password) authentication - } - - return d.deviceInfo, capabilities, nil -} - -// Status implements the SDK Device interface. -func (d *Device) Status(ctx context.Context) (sdk.DeviceMetrics, error) { - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - - if d.lastStatus != nil && time.Since(d.lastStatusAt) < d.statusTTL { - slog.Debug("Returning cached status", "deviceID", d.id) - return *d.lastStatus, nil - } - - slog.Debug("Fetching fresh status from Antminer", "deviceID", d.id) - - minerStatus, err := d.client.GetStatus(ctx) - if err != nil { - return sdk.DeviceMetrics{}, fmt.Errorf("failed to get miner status: %w", err) - } - - telemetry, err := d.client.GetTelemetry(ctx) - if err != nil { - slog.Warn("Failed to get telemetry data", "deviceID", d.id, "error", err) - } - - status := d.convertStatus(minerStatus, telemetry) - - d.refreshFirmwareVersion(ctx, &status) - - d.lastStatus = &status - d.lastStatusAt = time.Now() - - return status, nil -} - -// refreshFirmwareVersion periodically re-fetches firmware version from the device -// to detect firmware updates. Throttled to avoid excessive API calls. -func (d *Device) refreshFirmwareVersion(ctx context.Context, metrics *sdk.DeviceMetrics) { - if time.Since(d.lastFirmwareCheckAt) < firmwareRefreshInterval { - return - } - d.lastFirmwareCheckAt = time.Now() - versionResp, err := d.client.GetVersion(ctx) - if err != nil { - slog.Debug("failed to get version during Status", "error", err) - return - } - if len(versionResp.Version) > 0 { - fw := versionResp.Version[0].BMMiner - if fw == "" { - fw = versionResp.Version[0].Miner - } - d.deviceInfo.FirmwareVersion = fw - metrics.FirmwareVersion = fw - } -} - -// GetErrors returns all active and historical errors for the device. -// Since CGMiner RPC provides point-in-time metrics (not historical errors), -// errors are detected heuristically from current metric values. -func (d *Device) GetErrors(ctx context.Context) (sdk.DeviceErrors, error) { - // Fetch data from both RPC and Web API in parallel - collect all available data even if some calls fail - var summaryResp *rpc.SummaryResponse - var devsResp *rpc.DevsResponse - var poolsResp *rpc.PoolsResponse - var statsResp *web.StatsInfo - var sleeping bool - - g := new(errgroup.Group) - - g.Go(func() error { - var err error - if summaryResp, err = d.client.GetSummary(ctx); err != nil { - slog.Warn("Failed to get summary for error detection", "deviceID", d.id, "error", err) - } - return nil - }) - - g.Go(func() error { - var err error - if devsResp, err = d.client.GetDevs(ctx); err != nil { - slog.Warn("Failed to get devs for error detection", "deviceID", d.id, "error", err) - } - return nil - }) - - g.Go(func() error { - var err error - if poolsResp, err = d.client.GetPools(ctx); err != nil { - slog.Warn("Failed to get pools for error detection", "deviceID", d.id, "error", err) - } - return nil - }) - - g.Go(func() error { - var err error - if statsResp, err = d.client.GetStatsInfo(ctx); err != nil { - slog.Warn("Failed to get stats for error detection", "deviceID", d.id, "error", err) - } - return nil - }) - - g.Go(func() error { - config, err := d.client.GetMinerConfig(ctx) - if err != nil { - slog.Debug("Failed to get miner config for error detection", "deviceID", d.id, "error", err) - return nil - } - - sleeping = isSleepMode(config) - return nil - }) - - _ = g.Wait() // We're collecting data even if some calls fail, so we ignore the error - - // Detect errors from the collected data - errors := detectErrors(summaryResp, devsResp, poolsResp, statsResp, d.id, sleeping) - - return sdk.DeviceErrors{ - DeviceID: d.id, - Errors: errors, - }, nil -} - -// convertStatus converts Antminer-specific status to SDK format. -func (d *Device) convertStatus(minerStatus *antminer.Status, telemetry *antminer.Telemetry) sdk.DeviceMetrics { - now := time.Now() - - // Determine health status based on miner state and performance - health := minerStatus.State - var healthReason *string - - // Refine health status based on telemetry - // Health status hierarchy: Critical > Warning > Active/Inactive > Unknown - // We may upgrade healthy states to warning/critical based on telemetry - // TODO: Move this mapping to fleet side so plugins don't need to handle every SDK - // health status. Fleet should map unknown statuses to sensible defaults. - switch minerStatus.State { - case sdk.HealthHealthyActive: - // Detect if active miner has no hash rate, which may indicate an issue - if telemetry != nil && telemetry.HashrateHS != nil && *telemetry.HashrateHS == 0 { - health = sdk.HealthWarning - healthReason = ptrString("Mining but no hashrate detected") - } - case sdk.HealthHealthyInactive, sdk.HealthNeedsMiningPool: - // Idle state is normal, needs mining pool is handled by device status - case sdk.HealthWarning, sdk.HealthCritical: - // Use error message as health reason - if minerStatus.ErrorMessage != "" { - healthReason = &minerStatus.ErrorMessage - } - case sdk.HealthUnknown: - healthReason = ptrString("Status unknown") - case sdk.HealthStatusUnspecified: - healthReason = ptrString("Status unspecified") - } - - metrics := sdk.DeviceMetrics{ - DeviceID: d.id, - Timestamp: now, - FirmwareVersion: d.deviceInfo.FirmwareVersion, - Health: health, - HealthReason: healthReason, - } - - // Add telemetry data if available - if telemetry != nil { - metrics.HashrateHS = setMetricIfNotNil(telemetry.HashrateHS) - metrics.TempC = setMetricIfNotNil(telemetry.TemperatureCelsius) - metrics.FanRPM = setMetricIfNotNil(telemetry.FanRPM) - metrics.PowerW = setMetricIfNotNil(telemetry.PowerWatts) - metrics.EfficiencyJH = setMetricIfNotNil(telemetry.EfficiencyJPerHash) - - // Map per-hashboard telemetry to SDK metrics - for _, hb := range telemetry.HashBoards { - // #nosec G115 -- ChipCount and chain Index are small hardware constants (0-255) - chipCount := int32(hb.ChipCount) - sdkHB := sdk.HashBoardMetrics{ - ComponentInfo: sdk.ComponentInfo{ - // #nosec G115 -- chain Index is a small hardware constant (0-2 for typical miners) - Index: int32(hb.Index), - Name: fmt.Sprintf("Chain %d", hb.Index), - Status: sdk.ComponentStatusHealthy, - }, - HashRateHS: setMetricIfNotNil(hb.HashrateHS), - TempC: setMetricIfNotNil(hb.Temperature), - InletTempC: setMetricIfNotNil(hb.InletTemp), - OutletTempC: setMetricIfNotNil(hb.OutletTemp), - ChipCount: &chipCount, - ChipFrequencyMHz: toMetricValue(float64(hb.ChipFrequencyMHz)), - } - if hb.SerialNumber != "" { - sdkHB.SerialNumber = &hb.SerialNumber - } - metrics.HashBoards = append(metrics.HashBoards, sdkHB) - } - - // Map per-fan telemetry to SDK metrics - for _, fan := range telemetry.Fans { - metrics.FanMetrics = append(metrics.FanMetrics, sdk.FanMetrics{ - ComponentInfo: sdk.ComponentInfo{ - // #nosec G115 -- fan Index is a small hardware constant (0-7 for typical miners) - Index: int32(fan.Index), - Name: fmt.Sprintf("Fan %d", fan.Index), - Status: sdk.ComponentStatusHealthy, - }, - RPM: toMetricValue(float64(fan.RPM)), - }) - } - - // Add uptime as a sensor metric if available - // Uptime is a counter (monotonically increasing) rather than a gauge - if telemetry.UptimeSeconds != nil { - metrics.SensorMetrics = []sdk.SensorMetrics{ - { - ComponentInfo: sdk.ComponentInfo{ - Name: sensorTypeUptime, - Status: sdk.ComponentStatusHealthy, - }, - Type: sensorTypeUptime, - Unit: unitSeconds, - Value: toMetricValueWithKind(float64(*telemetry.UptimeSeconds), sdk.MetricKindCounter), - }, - } - } - } - - return metrics -} - -// Close implements the SDK Device interface. -func (d *Device) Close(ctx context.Context) error { - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - slog.Debug("Closing Antminer device", "deviceID", d.id) - - if d.client != nil { - d.client.Close() - } - - d.lastStatus = nil - - return nil -} - -// StartMining and StopMining use bitmain-work-mode via the web API. - -// StartMining implements the SDK Device interface. -func (d *Device) StartMining(ctx context.Context) error { - if err := d.startMining(ctx); err != nil { - return err - } - d.clearCurtailmentState() - d.invalidateStatusCache() - return nil -} - -// StopMining implements the SDK Device interface. -func (d *Device) StopMining(ctx context.Context) error { - if err := d.stopMining(ctx); err != nil { - return err - } - d.clearCurtailmentState() - d.invalidateStatusCache() - return nil -} - -func (d *Device) startMining(ctx context.Context) error { - return d.client.StartMining(ctx) -} - -func (d *Device) stopMining(ctx context.Context) error { - return d.client.StopMining(ctx) -} - -func (d *Device) invalidateStatusCache() { - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - d.lastStatus = nil - d.lastStatusAt = time.Time{} -} - -// Curtail implements FULL curtailment via StopMining. -func (d *Device) Curtail(ctx context.Context, req sdk.CurtailRequest) error { - if req.Level != sdk.CurtailLevelFull { - return sdk.NewErrCurtailCapabilityNotSupported(d.id, int32(req.Level)) - } - - d.invalidateStatusCache() - status, err := d.Status(ctx) - if err != nil { - return wrapCurtailDispatchError(d.id, err) - } - wasMining := isMiningHealth(status.Health) - - if err := d.stopMining(ctx); err != nil { - return wrapCurtailDispatchError(d.id, err) - } - - d.recordFullCurtailment(wasMining) - d.invalidateStatusCache() - return nil -} - -// Uncurtail restores mining via StartMining. -func (d *Device) Uncurtail(ctx context.Context, _ sdk.UncurtailRequest) error { - restoreMining, ok := d.fullCurtailRestoreDecision() - shouldStart := !ok || restoreMining - if shouldStart { - if err := d.startMining(ctx); err != nil { - return wrapCurtailDispatchError(d.id, err) - } - } - - d.clearCurtailmentState() - d.invalidateStatusCache() - return nil -} - -func (d *Device) recordFullCurtailment(wasMining bool) { - d.curtailmentMutex.Lock() - defer d.curtailmentMutex.Unlock() - if d.preFullCurtailMiningState == fullCurtailMiningStateUnknown { - d.preFullCurtailMiningState = miningStateBeforeFullCurtail(wasMining) - } -} - -func (d *Device) fullCurtailRestoreDecision() (bool, bool) { - d.curtailmentMutex.Lock() - defer d.curtailmentMutex.Unlock() - return d.preFullCurtailMiningState.restoreMiningDecision() -} - -func (d *Device) clearCurtailmentState() { - d.curtailmentMutex.Lock() - defer d.curtailmentMutex.Unlock() - d.preFullCurtailMiningState = fullCurtailMiningStateUnknown -} - -func isMiningHealth(health sdk.HealthStatus) bool { - return health == sdk.HealthHealthyActive || health == sdk.HealthWarning -} - -func wrapCurtailDispatchError(deviceID string, err error) error { - if err == nil { - return nil - } - var sdkErr sdk.SDKError - if errors.As(err, &sdkErr) { - return err - } - if isAntminerAuthenticationError(err) { - return sdk.NewErrorAuthenticationFailed(deviceID, err) - } - return sdk.NewErrCurtailTransient(deviceID, err) -} - -func isAntminerAuthenticationError(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "unauthenticated") || - strings.Contains(msg, "authentication failed") || - strings.Contains(msg, "unauthorized") || - strings.Contains(msg, "credentials required") -} - -// SetCoolingMode implements the SDK Device interface. -func (d *Device) SetCoolingMode(ctx context.Context, mode sdk.CoolingMode) error { - return d.client.SetCoolingMode(ctx, web.CoolingMode(mode)) -} - -// GetCoolingMode implements the SDK Device interface. -// Antminer doesn't support cooling mode configuration, so this returns Unspecified. -func (d *Device) GetCoolingMode(_ context.Context) (sdk.CoolingMode, error) { - return sdk.CoolingModeUnspecified, nil -} - -// SetPowerTarget implements the SDK Device interface. -// Maps performance modes to Antminer work modes: -// - MAXIMUM_HASHRATE -> bitmain-work-mode = "0" (normal operation) -// - EFFICIENCY -> bitmain-work-mode = "2" (low power mode) -func (d *Device) SetPowerTarget(ctx context.Context, performanceMode sdk.PerformanceMode) error { - slog.Info("Setting power target via work mode", "deviceID", d.id, "performanceMode", performanceMode) - - // Map performance mode to work mode - var workMode web.BitmainWorkMode - switch performanceMode { - case sdk.PerformanceModeMaximumHashrate: - workMode = web.BitmainWorkModeStart // "0" - Normal operation - case sdk.PerformanceModeEfficiency: - workMode = web.BitmainWorkModeLowPower // "2" - Low power mode - case sdk.PerformanceModeUnspecified: - return fmt.Errorf("performance mode must be specified for Antminer devices") - default: - return fmt.Errorf("unsupported performance mode: %v", performanceMode) - } - - // Get current configuration - config, err := d.client.GetMinerConfig(ctx) - if err != nil { - return fmt.Errorf("failed to get current miner config: %w", err) - } - - // Update work mode - config.BitmainWorkMode = workMode - - // Apply configuration - if err := d.client.SetMinerConfig(ctx, config); err != nil { - return fmt.Errorf("failed to set work mode: %w", err) - } - - // Clear cached status to force refresh on next Status() call - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - d.lastStatus = nil - - slog.Info("Successfully set work mode", "deviceID", d.id, "workMode", workMode) - return nil -} - -// UpdateMiningPools implements the SDK Device interface. -func (d *Device) UpdateMiningPools(ctx context.Context, pools []sdk.MiningPoolConfig) error { - var antminerPools []antminer.Pool - for _, p := range pools { - antminerPools = append(antminerPools, antminer.Pool{ - Priority: int(p.Priority), - URL: p.URL, - WorkerName: p.WorkerName, - }) - } - return d.client.UpdatePools(ctx, antminerPools) -} - -// GetMiningPools implements the SDK Device interface. -// Retrieves the currently configured mining pools from the Antminer via RPC. -func (d *Device) GetMiningPools(ctx context.Context) ([]sdk.ConfiguredPool, error) { - slog.Debug("Getting mining pools", "deviceID", d.id) - - poolsResp, err := d.client.GetPools(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get mining pools: %w", err) - } - - pools := make([]sdk.ConfiguredPool, 0, len(poolsResp.Pools)) - for _, pool := range poolsResp.Pools { - // Only include pools that have a URL configured - if pool.URL != "" { - pools = append(pools, sdk.ConfiguredPool{ - // #nosec G115 -- Pool priorities are protocol-bounded (0-2 for default/backup1/backup2) - Priority: int32(pool.Priority), - URL: pool.URL, - Username: pool.User, - }) - } - } - - return pools, nil -} - -// BlinkLED implements the SDK Device interface. -func (d *Device) BlinkLED(ctx context.Context) error { - return d.client.BlinkLED(ctx, blinkLEDDuration) -} - -// DownloadLogs implements the SDK Device interface. -// Retrieves kernel logs from the Antminer device via the web API. -func (d *Device) DownloadLogs(ctx context.Context, since *time.Time, _ string) (string, bool, error) { - logs, hasMore, err := d.client.GetLogs(ctx, since, 0) - if err != nil { - return "", false, fmt.Errorf("failed to download logs: %w", err) - } - return logs, hasMore, nil -} - -// Reboot implements the SDK Device interface. -func (d *Device) Reboot(ctx context.Context) error { - return d.client.Reboot(ctx) -} - -// FirmwareUpdate implements the SDK Device interface. -// -// The firmware file is uploaded to the Antminer via the CGI upgrade endpoint -// (POST /cgi-bin/upgrade.cgi, multipart/form-data with digest auth). -func (d *Device) FirmwareUpdate(ctx context.Context, firmware sdk.FirmwareFile) error { - if firmware.Reader == nil { - return fmt.Errorf("firmware file is required for file-based firmware update") - } - - return d.client.UploadFirmware(ctx, firmware) -} - -func (d *Device) Unpair(ctx context.Context) error { - // No specific unpair action needed for Antminer devices - // Unpair is handled optimistically at the database level - return nil -} - -// UpdateMinerPassword implements the SDK Device interface. -func (d *Device) UpdateMinerPassword(ctx context.Context, currentPassword string, newPassword string) error { - d.statusMutex.Lock() - defer d.statusMutex.Unlock() - - // Clear cached status since credentials are changing - d.lastStatus = nil - d.lastStatusAt = time.Time{} - - // Update password via web API using current password for verification - if err := d.client.ChangePassword(ctx, currentPassword, newPassword); err != nil { - return fmt.Errorf("failed to update miner password: %w", err) - } - - // Update stored credentials for future API calls (only password changes) - d.credentials.Password = newPassword - if err := d.client.SetCredentials(d.credentials); err != nil { - return fmt.Errorf("failed to update client credentials: %w", err) - } - - return nil -} - -// Optional capabilities - these return false to indicate they're not supported - -func (d *Device) TryBatchStatus(ctx context.Context, _ []string) (map[string]sdk.DeviceMetrics, bool, error) { - return nil, false, nil // Not supported by individual devices -} - -func (d *Device) TrySubscribe(ctx context.Context, _ []string) (<-chan sdk.DeviceMetrics, bool, error) { - return nil, false, nil // Streaming not supported -} - -func (d *Device) TryGetWebViewURL(ctx context.Context) (string, bool, error) { - host := d.deviceInfo.Host - if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { - host = "[" + host + "]" - } - url := fmt.Sprintf("%s://%s", d.deviceInfo.URLScheme, host) - return url, true, nil -} - -func (d *Device) TryGetTimeSeriesData(ctx context.Context, _ []string, _, _ time.Time, _ *time.Duration, _ int32, _ string) ([]sdk.DeviceMetrics, string, bool, error) { - return nil, "", false, nil // Time series not supported -} diff --git a/plugin/antminer/internal/device/device_test.go b/plugin/antminer/internal/device/device_test.go deleted file mode 100644 index c9f5ac32be..0000000000 --- a/plugin/antminer/internal/device/device_test.go +++ /dev/null @@ -1,1174 +0,0 @@ -package device - -import ( - "errors" - "math" - "testing" - - "github.com/block/proto-fleet/plugin/antminer/internal/types" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/mocks" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdk "github.com/block/proto-fleet/server/sdk/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -// Test constants to reduce duplication -const ( - testDeviceID = "test-device-001" - testHost = "192.168.1.100" - testUsername = "admin" - testPassword = "password" - testFirmware = "test-firmware" - testModel = "Antminer S19" - testManufacturer = "Bitmain" -) - -// testDeviceInfo returns a standard DeviceInfo for testing -func testDeviceInfo() sdk.DeviceInfo { - return sdk.DeviceInfo{ - Host: testHost, - Port: 80, - URLScheme: "http", - Model: testModel, - Manufacturer: testManufacturer, - FirmwareVersion: testFirmware, - } -} - -// testCredentials returns standard credentials for testing -func testCredentials() sdk.UsernamePassword { - return sdk.UsernamePassword{ - Username: testUsername, - Password: testPassword, - } -} - -// mockClientFactory creates a client factory that returns the given mock client -func mockClientFactory(mockClient antminer.AntminerClient) types.ClientFactory { - return func(_ string, _, _ int32, _ string) (antminer.AntminerClient, error) { - return mockClient, nil - } -} - -// mockClientFactoryWithAssertions creates a client factory with parameter assertions -func mockClientFactoryWithAssertions(t *testing.T, mockClient antminer.AntminerClient) types.ClientFactory { - return func(host string, rpcPort, webPort int32, urlScheme string) (antminer.AntminerClient, error) { - assert.Equal(t, testHost, host) - assert.Equal(t, int32(4028), rpcPort) - assert.Equal(t, int32(80), webPort) - assert.Equal(t, "http", urlScheme) - return mockClient, nil - } -} - -// setupMockForDeviceCreation sets up standard mock expectations for device creation (New only) -func setupMockForDeviceCreation(mockClient *mocks.MockAntminerClient) { - mockClient.EXPECT().SetCredentials(sdk.UsernamePassword{Username: testUsername, Password: testPassword}).Return(nil) -} - -// setupMockForDeviceConnection sets up standard mock expectations for device connection (Connect) -func setupMockForDeviceConnection(mockClient *mocks.MockAntminerClient, status *antminer.Status, telemetry *antminer.Telemetry) { - mockClient.EXPECT().GetStatus(gomock.Any()).Return(status, nil) - - if telemetry != nil { - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(telemetry, nil) - } else { - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(nil, assert.AnError) - } -} - -// createTestDevice creates a device with standard test setup -func createTestDevice(t *testing.T, mockClient *mocks.MockAntminerClient, status *antminer.Status, telemetry *antminer.Telemetry) *Device { - setupMockForDeviceCreation(mockClient) - setupMockForDeviceConnection(mockClient, status, telemetry) - - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactory(mockClient), - ) - require.NoError(t, err) - require.NotNil(t, device) - - err = device.Connect(t.Context()) - require.NoError(t, err) - return device -} - -// cleanupDevice closes the device and expects the Close call on the mock -func cleanupDevice(t *testing.T, device *Device, mockClient *mocks.MockAntminerClient) { - mockClient.EXPECT().Close() - err := device.Close(t.Context()) - require.NoError(t, err) -} - -// assertMetricValue validates that a telemetry value matches a metric value -func assertMetricValue(t *testing.T, expected *float64, actual *sdk.MetricValue, msgAndArgs ...interface{}) { - if expected != nil && *expected > 0 { - require.NotNil(t, actual, msgAndArgs...) - assert.InEpsilon(t, *expected, actual.Value, 0.01, msgAndArgs...) - } -} - -// defaultStatus returns a standard healthy status for testing -func defaultStatus() *antminer.Status { - return &antminer.Status{ - State: sdk.HealthHealthyActive, - FirmwareVersion: testFirmware, - ErrorMessage: "", - } -} - -// defaultTelemetry returns standard telemetry data for testing -func defaultTelemetry() *antminer.Telemetry { - return &antminer.Telemetry{ - HashrateHS: ptrFloat64(100e12), // 100 TH/s - UptimeSeconds: ptrInt64(86400), // 1 day uptime - } -} - -func TestDevice_New(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // For this test, we only want to verify device creation, not connection - setupMockForDeviceCreation(mockClient) - - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactoryWithAssertions(t, mockClient), // Use the version that validates parameters - ) - require.NoError(t, err) - require.NotNil(t, device) - - // Verify device properties - assert.Equal(t, testDeviceID, device.ID()) - assert.Equal(t, testDeviceInfo(), device.deviceInfo) - assert.Equal(t, testCredentials(), device.credentials) - assert.Equal(t, mockClient, device.client) - - // Clean up - just expect Close call - mockClient.EXPECT().Close() - err = device.Close(t.Context()) - require.NoError(t, err) -} - -func TestDevice_Connect(t *testing.T) { - ctx := t.Context() - - t.Run("successful_connection", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // Set up expectations for device creation - setupMockForDeviceCreation(mockClient) - - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactory(mockClient), - ) - require.NoError(t, err) - require.NotNil(t, device) - - // Set up expectations for connection - setupMockForDeviceConnection(mockClient, defaultStatus(), defaultTelemetry()) - - // Test Connect - err = device.Connect(ctx) - require.NoError(t, err) - - // Clean up - mockClient.EXPECT().Close() - err = device.Close(ctx) - require.NoError(t, err) - }) - - t.Run("connection_failure", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // Set up expectations for device creation - setupMockForDeviceCreation(mockClient) - - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactory(mockClient), - ) - require.NoError(t, err) - require.NotNil(t, device) - - // Set up expectations for failed connection - mockClient.EXPECT().GetStatus(gomock.Any()).Return(nil, assert.AnError) - mockClient.EXPECT().Close() // Should be called when connection fails - - // Test Connect failure - err = device.Connect(ctx) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to verify device communication") - - // Device should already be closed due to connection failure - }) -} - -func TestDevice_Status(t *testing.T) { - ctx := t.Context() - - testCases := []struct { - name string - minerStatus *antminer.Status - telemetry *antminer.Telemetry - expectedHealth sdk.HealthStatus - }{ - { - name: "mining_with_hashrate", - minerStatus: &antminer.Status{ - State: sdk.HealthHealthyActive, - FirmwareVersion: testFirmware, - ErrorMessage: "", - }, - telemetry: &antminer.Telemetry{ - HashrateHS: ptrFloat64(100e12), // 100 TH/s - TemperatureCelsius: ptrFloat64(70), - FanRPM: ptrFloat64(4000), - UptimeSeconds: ptrInt64(86400), - }, - expectedHealth: sdk.HealthHealthyActive, - }, - { - name: "mining_no_hashrate", - minerStatus: &antminer.Status{ - State: sdk.HealthHealthyActive, - FirmwareVersion: testFirmware, - ErrorMessage: "", - }, - telemetry: &antminer.Telemetry{ - HashrateHS: ptrFloat64(0), // No hashrate - }, - expectedHealth: sdk.HealthWarning, - }, - { - name: "idle_state", - minerStatus: &antminer.Status{ - State: sdk.HealthHealthyInactive, - FirmwareVersion: testFirmware, - ErrorMessage: "", - }, - telemetry: nil, - expectedHealth: sdk.HealthHealthyInactive, - }, - { - name: "warning_state", - minerStatus: &antminer.Status{ - State: sdk.HealthWarning, - FirmwareVersion: testFirmware, - ErrorMessage: "High temperature", - }, - telemetry: nil, - expectedHealth: sdk.HealthWarning, - }, - { - name: "error_state", - minerStatus: &antminer.Status{ - State: sdk.HealthCritical, - FirmwareVersion: testFirmware, - ErrorMessage: "Hardware failure", - }, - telemetry: nil, - expectedHealth: sdk.HealthCritical, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, tc.minerStatus, tc.telemetry) - defer cleanupDevice(t, device, mockClient) - - // Get the status (should use cached result from creation) - status, err := device.Status(ctx) - require.NoError(t, err) - - // Verify results - assert.Equal(t, testDeviceID, status.DeviceID) - assert.Equal(t, tc.expectedHealth, status.Health) - - // Verify health reason for error cases - if tc.minerStatus.ErrorMessage != "" { - require.NotNil(t, status.HealthReason) - assert.Equal(t, tc.minerStatus.ErrorMessage, *status.HealthReason) - } - - // Verify telemetry data if provided - now wrapped in MetricValue - if tc.telemetry != nil { - assertMetricValue(t, tc.telemetry.HashrateHS, status.HashrateHS) - assertMetricValue(t, tc.telemetry.TemperatureCelsius, status.TempC) - assertMetricValue(t, tc.telemetry.FanRPM, status.FanRPM) - } - - // Verify SensorMetrics for uptime if provided - if tc.telemetry != nil && tc.telemetry.UptimeSeconds != nil { - require.NotNil(t, status.SensorMetrics) - require.Len(t, status.SensorMetrics, 1) - uptimeSensor := status.SensorMetrics[0] - assert.Equal(t, "uptime", uptimeSensor.Type) - assert.Equal(t, "seconds", uptimeSensor.Unit) - assert.Equal(t, "uptime", uptimeSensor.Name) - assert.Equal(t, sdk.ComponentStatusHealthy, uptimeSensor.Status) - require.NotNil(t, uptimeSensor.Value) - assert.InEpsilon(t, float64(*tc.telemetry.UptimeSeconds), uptimeSensor.Value.Value, 0.01) - assert.Equal(t, sdk.MetricKindCounter, uptimeSensor.Value.Kind) - } - }) - } -} - -func TestDevice_StatusCaching(t *testing.T) { - ctx := t.Context() - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // Set up expectations for device creation - setupMockForDeviceCreation(mockClient) - - // Create device (no status calls yet) - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactory(mockClient), - ) - require.NoError(t, err) - require.NotNil(t, device) - defer cleanupDevice(t, device, mockClient) - - // Set up expectations for connection (this will populate the cache) - setupMockForDeviceConnection(mockClient, defaultStatus(), defaultTelemetry()) - - // Connect the device (this will cache the first status) - err = device.Connect(ctx) - require.NoError(t, err) - - // First call should use cached result from Connect (no additional RPC calls) - status1, err := device.Status(ctx) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyActive, status1.Health) - - // Second call should also use cached result (no additional RPC calls) - status2, err := device.Status(ctx) - require.NoError(t, err) - assert.Equal(t, status1, status2) - - // Verify that both calls returned the same cached data - assert.Equal(t, status1.Timestamp, status2.Timestamp, "Cached status should have same timestamp") - assert.Equal(t, status1.Health, status2.Health, "Cached status should have same health") -} - -func TestDevice_StatusNoCache(t *testing.T) { - ctx := t.Context() - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // Set up expectations for device creation - setupMockForDeviceCreation(mockClient) - - // Create device (no status calls yet) - device, err := New( - testDeviceID, - testDeviceInfo(), - testCredentials(), - mockClientFactory(mockClient), - ) - device.statusTTL = 0 // Disable caching for this test - require.NoError(t, err) - require.NotNil(t, device) - defer cleanupDevice(t, device, mockClient) - - // Set up expectations for first status call (Connect calls Status internally) - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - - // Connect the device (this will call Status once) - err = device.Connect(ctx) - require.NoError(t, err) - - // Set up expectations for second status call (should invoke RPC again due to no caching) - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - - // First explicit call should fetch fresh data (no cache due to TTL=0) - status1, err := device.Status(ctx) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyActive, status1.Health) - - // Set up expectations for third status call (should invoke RPC again) - updatedStatus := &antminer.Status{ - State: sdk.HealthHealthyInactive, - FirmwareVersion: testFirmware, - ErrorMessage: "", - } - mockClient.EXPECT().GetStatus(gomock.Any()).Return(updatedStatus, nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(nil, assert.AnError) // No telemetry - - // Second explicit call should fetch fresh data again - status2, err := device.Status(ctx) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyInactive, status2.Health) - - // Verify that the two statuses are different - assert.NotEqual(t, status1.Timestamp, status2.Timestamp, "Statuses should have different timestamps") - assert.NotEqual(t, status1.Health, status2.Health, "Statuses should have different health statuses") -} - -func TestDevice_DescribeDevice(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Test DescribeDevice - info, capabilities, err := device.DescribeDevice(t.Context()) - require.NoError(t, err) - - // Verify device info - assert.Equal(t, testDeviceInfo(), info) - - // Verify capabilities - assert.True(t, capabilities[sdk.CapabilityPollingHost]) - assert.True(t, capabilities[sdk.CapabilityReboot]) - assert.True(t, capabilities[sdk.CapabilityFirmware]) - assert.True(t, capabilities[sdk.CapabilityPoolConfig]) - assert.True(t, capabilities[sdk.CapabilityBasicAuth]) - assert.True(t, capabilities[sdk.CapabilityMiningStart]) - assert.True(t, capabilities[sdk.CapabilityMiningStop]) - assert.True(t, capabilities[sdk.CapabilityCurtailFull]) - assert.False(t, capabilities[sdk.CapabilityCurtailEfficiency]) -} - -func ptrFloat64(v float64) *float64 { - return &v -} - -func ptrInt64(v int64) *int64 { - return &v -} - -func TestDevice_StopMining(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Set up expectation for StopMining - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - - // Test StopMining - err := device.StopMining(t.Context()) - require.NoError(t, err) -} - -func TestDevice_StartMining(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Set up expectation for StartMining - mockClient.EXPECT().StartMining(gomock.Any()).Return(nil) - - // Test StartMining - err := device.StartMining(t.Context()) - require.NoError(t, err) -} - -func TestDevice_CurtailFullInvalidatesStatusCache(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - require.NotNil(t, device.lastStatus) - require.False(t, device.lastStatusAt.IsZero()) - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - - err := device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull}) - - require.NoError(t, err) - assert.Nil(t, device.lastStatus) - assert.True(t, device.lastStatusAt.IsZero()) -} - -func TestDevice_CurtailFullOnActiveMinerUncurtailStartsMining(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - require.NoError(t, device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull})) - - mockClient.EXPECT().StartMining(gomock.Any()).Return(nil) - require.NoError(t, device.Uncurtail(t.Context(), sdk.UncurtailRequest{})) -} - -func TestDevice_CurtailFullOnInactiveMinerUncurtailDoesNotStartMining(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - status := defaultStatus() - status.State = sdk.HealthHealthyInactive - device := createTestDevice(t, mockClient, status, defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().GetStatus(gomock.Any()).Return(status, nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - require.NoError(t, device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull})) - - require.NoError(t, device.Uncurtail(t.Context(), sdk.UncurtailRequest{})) -} - -func TestDevice_CurtailFullWrapsDispatchFailureAsTransient(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(assert.AnError) - - err := device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull}) - - require.Error(t, err) - var sdkErr sdk.SDKError - require.True(t, errors.As(err, &sdkErr)) - assert.Equal(t, sdk.ErrCodeCurtailTransient, sdkErr.Code) - assert.ErrorIs(t, err, assert.AnError) - _, ok := device.fullCurtailRestoreDecision() - assert.False(t, ok) -} - -func TestDevice_CurtailFullPreservesAuthenticationFailure(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - authErr := errors.New("failed to get current miner config: credentials required for miner configuration") - mockClient.EXPECT().GetStatus(gomock.Any()).Return(defaultStatus(), nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(authErr) - - err := device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull}) - - require.Error(t, err) - var sdkErr sdk.SDKError - require.True(t, errors.As(err, &sdkErr)) - assert.Equal(t, sdk.ErrCodeAuthenticationFailed, sdkErr.Code) - assert.ErrorIs(t, err, authErr) - _, ok := device.fullCurtailRestoreDecision() - assert.False(t, ok) -} - -func TestDevice_CurtailUnsupportedLevelReturnsCapabilityNotSupported(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - err := device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelEfficiency}) - - require.Error(t, err) - var sdkErr sdk.SDKError - require.True(t, errors.As(err, &sdkErr)) - assert.Equal(t, sdk.ErrCodeCurtailCapabilityNotSupported, sdkErr.Code) -} - -func TestDevice_CurtailFullRefreshesStatusBeforeSnapshot(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - stoppedStatus := defaultStatus() - stoppedStatus.State = sdk.HealthHealthyInactive - mockClient.EXPECT().GetStatus(gomock.Any()).Return(stoppedStatus, nil) - mockClient.EXPECT().GetTelemetry(gomock.Any()).Return(defaultTelemetry(), nil) - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - - require.NoError(t, device.Curtail(t.Context(), sdk.CurtailRequest{Level: sdk.CurtailLevelFull})) - require.NoError(t, device.Uncurtail(t.Context(), sdk.UncurtailRequest{})) -} - -func TestDevice_UncurtailInvalidatesStatusCache(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - require.NotNil(t, device.lastStatus) - require.False(t, device.lastStatusAt.IsZero()) - mockClient.EXPECT().StartMining(gomock.Any()).Return(nil) - - err := device.Uncurtail(t.Context(), sdk.UncurtailRequest{}) - - require.NoError(t, err) - assert.Nil(t, device.lastStatus) - assert.True(t, device.lastStatusAt.IsZero()) -} - -func TestDevice_UncurtailWrapsDispatchFailureAsTransient(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().StartMining(gomock.Any()).Return(assert.AnError) - - err := device.Uncurtail(t.Context(), sdk.UncurtailRequest{}) - - require.Error(t, err) - var sdkErr sdk.SDKError - require.True(t, errors.As(err, &sdkErr)) - assert.Equal(t, sdk.ErrCodeCurtailTransient, sdkErr.Code) - assert.ErrorIs(t, err, assert.AnError) -} - -func TestDevice_UncurtailPreservesAuthenticationFailure(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - authErr := errors.New("unauthorized") - mockClient.EXPECT().StartMining(gomock.Any()).Return(authErr) - - err := device.Uncurtail(t.Context(), sdk.UncurtailRequest{}) - - require.Error(t, err) - var sdkErr sdk.SDKError - require.True(t, errors.As(err, &sdkErr)) - assert.Equal(t, sdk.ErrCodeAuthenticationFailed, sdkErr.Code) - assert.ErrorIs(t, err, authErr) -} - -func TestDevice_ManualMiningControlClearsCurtailmentState(t *testing.T) { - t.Run("start", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - device.recordFullCurtailment(true) - - mockClient.EXPECT().StartMining(gomock.Any()).Return(nil) - require.NoError(t, device.StartMining(t.Context())) - - _, ok := device.fullCurtailRestoreDecision() - assert.False(t, ok) - }) - - t.Run("stop", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - device.recordFullCurtailment(true) - - mockClient.EXPECT().StopMining(gomock.Any()).Return(nil) - require.NoError(t, device.StopMining(t.Context())) - - _, ok := device.fullCurtailRestoreDecision() - assert.False(t, ok) - }) -} - -func TestDevice_Reboot(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Set up expectation for Reboot - mockClient.EXPECT().Reboot(gomock.Any()).Return(nil) - - // Test Reboot - err := device.Reboot(t.Context()) - require.NoError(t, err) -} - -func TestDevice_UpdateMiningPools(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Define new pools to update - expectedPools := []antminer.Pool{ - { - Priority: 1, - URL: "stratum+tcp://pool1.example.com:3333", - WorkerName: "worker1", - }, - { - Priority: 2, - URL: "stratum+tcp://pool2.example.com:4444", - WorkerName: "worker2", - }, - } - - newPools := []sdk.MiningPoolConfig{} - for _, p := range expectedPools { - if p.Priority < 1 || p.Priority > math.MaxInt32 { - t.Fatalf("invalid pool priority: %d", p.Priority) - } - priority := int32(p.Priority) - - newPools = append(newPools, sdk.MiningPoolConfig{ - Priority: priority, - URL: p.URL, - WorkerName: p.WorkerName, - }) - } - - // Set up expectation for UpdatePools - mockClient.EXPECT().UpdatePools(t.Context(), expectedPools).Return(nil) - - // Test UpdateMiningPools - err := device.UpdateMiningPools(t.Context(), newPools) - require.NoError(t, err) -} - -func TestDevice_UpdateMinerPassword(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - currentPassword := "password" // testPassword from device initialization - newPassword := "newpassword" - - // Expected credentials after update (username unchanged, only password updated) - expectedCredentials := sdk.UsernamePassword{ - Username: testUsername, // Username stays the same - Password: newPassword, // Only password changes - } - - // Set up expectations for ChangePassword and SetCredentials - mockClient.EXPECT().ChangePassword(t.Context(), currentPassword, newPassword).Return(nil) - mockClient.EXPECT().SetCredentials(expectedCredentials).Return(nil) - - // Test UpdateMinerPassword - err := device.UpdateMinerPassword(t.Context(), currentPassword, newPassword) - require.NoError(t, err) -} - -func TestDevice_UpdateMinerPassword_ChangePasswordFails(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - currentPassword := "wrongpassword" - newPassword := "newpassword" - - // Simulate password change failure (wrong current password) - mockClient.EXPECT().ChangePassword(t.Context(), currentPassword, newPassword).Return(assert.AnError) - - // Test UpdateMinerPassword - should fail - err := device.UpdateMinerPassword(t.Context(), currentPassword, newPassword) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to update miner password") -} - -func TestDevice_GetWebViewURL(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Test GetWebViewURL - url, ok, err := device.TryGetWebViewURL(t.Context()) - require.True(t, ok) - require.NoError(t, err) - assert.Equal(t, "http://192.168.1.100", url) -} - -func TestDevice_ID(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Test ID getter - id := device.ID() - assert.Equal(t, testDeviceID, id) -} - -func TestDevice_Close(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - - // Test Close - should call client.Close() and clear cached data - mockClient.EXPECT().Close() - err := device.Close(t.Context()) - require.NoError(t, err) - - // Verify cached data is cleared - assert.Nil(t, device.lastStatus) -} - -func TestDevice_SetCoolingMode(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Test SetCoolingMode - testMode := sdk.CoolingModeManual - mockClient.EXPECT().SetCoolingMode(gomock.Any(), web.CoolingMode(testMode)).Return(nil) - - err := device.SetCoolingMode(t.Context(), testMode) - require.NoError(t, err) -} - -func TestDevice_BlinkLED(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Test BlinkLED - mockClient.EXPECT().BlinkLED(gomock.Any(), blinkLEDDuration).Return(nil) - - err := device.BlinkLED(t.Context()) - require.NoError(t, err) -} - -func TestDevice_DownloadLogs(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - expectedLogs := "[ 0.000000] Booting Linux on physical CPU 0x0\n[ 1.200000] cgminer: Starting cgminer" - mockClient.EXPECT().GetLogs(gomock.Any(), nil, 0).Return(expectedLogs, false, nil) - - logs, hasMore, err := device.DownloadLogs(t.Context(), nil, "") - require.NoError(t, err) - assert.Equal(t, expectedLogs, logs) - assert.False(t, hasMore) -} - -func TestDevice_GetErrors(t *testing.T) { - ctx := t.Context() - - t.Run("healthy_device_no_errors", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Mock RPC calls returning healthy data - mockClient.EXPECT().GetSummary(gomock.Any()).Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - {HardwareErrors: 10, DeviceHardwarePercent: 0.1, DeviceRejectedPercent: 0.5}, - }, - }, nil) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(&rpc.DevsResponse{ - Devs: []rpc.DevInfo{ - {ASC: 0, Status: "Alive", Enabled: "Y", Temperature: 70.0, MHSAv: 100000000}, - }, - }, nil) - mockClient.EXPECT().GetPools(gomock.Any()).Return(&rpc.PoolsResponse{ - Pools: []rpc.PoolInfo{ - {Pool: 0, URL: "stratum+tcp://pool.example.com:3333", Status: "Alive"}, - }, - }, nil) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(&web.MinerConfig{ - BitmainWorkMode: web.BitmainWorkModeStart, - }, nil) - // Stats API returns error (credentials required) - should fallback to RPC devs - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(nil, assert.AnError) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Equal(t, testDeviceID, errors.DeviceID) - assert.Empty(t, errors.Errors, "Expected no errors for healthy device") - }) - - t.Run("device_with_errors", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Mock RPC calls returning problematic data - mockClient.EXPECT().GetSummary(gomock.Any()).Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - {HardwareErrors: 10, DeviceHardwarePercent: 0.1, DeviceRejectedPercent: 0.5}, - }, - }, nil) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(&rpc.DevsResponse{ - Devs: []rpc.DevInfo{ - {ASC: 0, Status: "Alive", Enabled: "Y", Temperature: 96.0, MHSAv: 100000000}, // Overheating - }, - }, nil) - mockClient.EXPECT().GetPools(gomock.Any()).Return(&rpc.PoolsResponse{ - Pools: []rpc.PoolInfo{ - {Pool: 0, URL: "stratum+tcp://pool.example.com:3333", Status: "Dead"}, // Pool down - }, - }, nil) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(&web.MinerConfig{ - BitmainWorkMode: web.BitmainWorkModeStart, - }, nil) - // Stats API returns error (credentials required) - should fallback to RPC devs - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(nil, assert.AnError) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Equal(t, testDeviceID, errors.DeviceID) - assert.Len(t, errors.Errors, 2, "Expected 2 errors (temperature + pool)") - }) - - t.Run("stats_api_success_no_fallback_to_devs", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // Mock RPC calls for summary and pools (still needed) - mockClient.EXPECT().GetSummary(gomock.Any()).Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - {HardwareErrors: 10, DeviceHardwarePercent: 0.1, DeviceRejectedPercent: 0.5}, - }, - }, nil) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(&rpc.DevsResponse{ - Devs: []rpc.DevInfo{ - // This data should NOT be used since Stats API succeeds - {ASC: 0, Status: "Alive", Enabled: "Y", Temperature: 96.0, MHSAv: 100000000}, // Would trigger error if used - }, - }, nil) - mockClient.EXPECT().GetPools(gomock.Any()).Return(&rpc.PoolsResponse{ - Pools: []rpc.PoolInfo{ - {Pool: 0, URL: "stratum+tcp://pool.example.com:3333", Status: "Alive"}, - }, - }, nil) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(&web.MinerConfig{ - BitmainWorkMode: web.BitmainWorkModeStart, - }, nil) - // Stats API succeeds with healthy chain data - should NOT fallback to RPC devs - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(&web.StatsInfo{ - STATS: []web.StatsData{ - { - Chain: []web.ChainStats{ - { - Index: 0, - RateReal: 13500.0, // Healthy hashrate - RateIdeal: 14000.0, - TempChip: []float64{70.0, 72.0, 71.0}, // Healthy temps - HW: 50, // Low HW errors - HWP: 0.05, // Low HW error percentage - SN: "test-chain-0", - }, - }, - }, - }, - }, nil) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Equal(t, testDeviceID, errors.DeviceID) - assert.Empty(t, errors.Errors, "Expected no errors when Stats API provides healthy data") - }) - - t.Run("rpc_failures_graceful_degradation", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - // All RPC calls fail - should still return empty errors, not fail - mockClient.EXPECT().GetSummary(gomock.Any()).Return(nil, assert.AnError) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(nil, assert.AnError) - mockClient.EXPECT().GetPools(gomock.Any()).Return(nil, assert.AnError) - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(nil, assert.AnError) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(nil, assert.AnError) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Equal(t, testDeviceID, errors.DeviceID) - assert.Empty(t, errors.Errors, "Expected empty errors when RPC fails") - }) - - t.Run("sleeping_device_suppresses_not_hashing_errors", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().GetSummary(gomock.Any()).Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - {HardwareErrors: 0, DeviceHardwarePercent: 0, DeviceRejectedPercent: 0}, - }, - }, nil) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(&rpc.DevsResponse{ - Devs: []rpc.DevInfo{ - {ASC: 0, Status: "Alive", Enabled: "Y", Temperature: 70.0, MHSAv: 0}, - }, - }, nil) - mockClient.EXPECT().GetPools(gomock.Any()).Return(&rpc.PoolsResponse{ - Pools: []rpc.PoolInfo{ - {Pool: 0, URL: "stratum+tcp://pool.example.com:3333", Status: "Alive"}, - }, - }, nil) - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(&web.StatsInfo{ - STATS: []web.StatsData{ - { - Chain: []web.ChainStats{ - {Index: 0, RateReal: 0, RateIdeal: 14000, TempChip: []float64{70, 70, 70}, SN: "chain-0"}, - {Index: 1, RateReal: 0, RateIdeal: 14000, TempChip: []float64{70, 70, 70}, SN: "chain-1"}, - {Index: 2, RateReal: 0, RateIdeal: 14000, TempChip: []float64{70, 70, 70}, SN: "chain-2"}, - }, - }, - }, - }, nil) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(&web.MinerConfig{ - BitmainWorkMode: web.BitmainWorkModeSleep, - }, nil) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Equal(t, testDeviceID, errors.DeviceID) - assert.Empty(t, errors.Errors, "Expected sleeping device to suppress not-hashing errors") - }) - - t.Run("awake_device_still_reports_not_hashing_errors", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - device := createTestDevice(t, mockClient, defaultStatus(), defaultTelemetry()) - defer cleanupDevice(t, device, mockClient) - - mockClient.EXPECT().GetSummary(gomock.Any()).Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - {HardwareErrors: 0, DeviceHardwarePercent: 0, DeviceRejectedPercent: 0}, - }, - }, nil) - mockClient.EXPECT().GetDevs(gomock.Any()).Return(&rpc.DevsResponse{ - Devs: []rpc.DevInfo{ - {ASC: 0, Status: "Alive", Enabled: "Y", Temperature: 70.0, MHSAv: 0}, - }, - }, nil) - mockClient.EXPECT().GetPools(gomock.Any()).Return(&rpc.PoolsResponse{ - Pools: []rpc.PoolInfo{ - {Pool: 0, URL: "stratum+tcp://pool.example.com:3333", Status: "Alive"}, - }, - }, nil) - mockClient.EXPECT().GetStatsInfo(gomock.Any()).Return(&web.StatsInfo{ - STATS: []web.StatsData{ - { - Chain: []web.ChainStats{ - {Index: 0, RateReal: 0, RateIdeal: 14000, TempChip: []float64{70, 70, 70}, SN: "chain-0"}, - }, - }, - }, - }, nil) - mockClient.EXPECT().GetMinerConfig(gomock.Any()).Return(&web.MinerConfig{ - BitmainWorkMode: web.BitmainWorkModeStart, - }, nil) - - errors, err := device.GetErrors(ctx) - require.NoError(t, err) - assert.Len(t, errors.Errors, 1, "Expected awake device to keep reporting not-hashing errors") - assert.Equal(t, "Hashboard 0 is not producing hashrate", errors.Errors[0].Summary) - }) -} diff --git a/plugin/antminer/internal/device/errors.go b/plugin/antminer/internal/device/errors.go deleted file mode 100644 index d77a88a3e8..0000000000 --- a/plugin/antminer/internal/device/errors.go +++ /dev/null @@ -1,825 +0,0 @@ -package device - -// This file implements error detection for Antminer devices. -// Unlike Proto firmware which reports explicit error codes, Antminer devices -// use CGMiner RPC which provides metrics. Errors are inferred heuristically -// from abnormal metric values (high temps, hardware errors, etc.). - -import ( - "fmt" - "log/slog" - "strconv" - "strings" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdkerrors "github.com/block/proto-fleet/server/sdk/v1/errors" -) - -// Temperature thresholds (Celsius) -const ( - tempOverheatCriticalCelsius = 95.0 - tempOverheatMajorCelsius = 85.0 - tempUnderheatMinorCelsius = 0.0 -) - -// Hardware error thresholds -const ( - hwErrorPercentMajor = 5.0 - hwErrorPercentMinor = 1.0 - hwErrorCountMinor int64 = 1000 -) - -// Share rejection thresholds -const ( - rejectedPercentMajor = 10.0 - rejectedPercentMinor = 5.0 - staleSharesThreshold int64 = 100 -) - -// Pool connectivity thresholds -const ( - poolFailuresThreshold int64 = 10 -) - -// Hashboard status constants -const ( - hashboardStatusAlive = "Alive" - hashboardEnabledYes = "Y" - poolStatusAlive = "Alive" -) - -// Hashboard error status types (used in helper functions) -const ( - statusNotHashing = "not_hashing" - statusDisabled = "disabled" - statusCommunicationLost = "communication_lost" -) - -// Vendor attribute keys -const ( - attrASCIndex = "asc_index" - attrChainIndex = "chain_index" - attrSerialNumber = "serial_number" - attrStatus = "status" - attrEnabled = "enabled" - attrMHSAv = "mhs_av" - attrTempCelsius = "temperature_celsius" - attrThresholdCelsius = "threshold_celsius" - attrHWErrorPercent = "hw_error_percent" - attrHWErrorCount = "hw_error_count" - attrThresholdPercent = "threshold_percent" - attrThresholdCount = "threshold_count" - attrRateRealGHS = "rate_real_ghs" - attrRateIdealGHS = "rate_ideal_ghs" - attrFanIndex = "fan_index" - attrFanRPM = "fan_rpm" - attrRejectedPercent = "rejected_percent" - attrRejectedCount = "rejected_count" - attrAcceptedCount = "accepted_count" - attrStaleCount = "stale_count" - attrPoolStalePercent = "pool_stale_percent" - attrPoolURL = "pool_url" - attrPoolStatus = "pool_status" - attrPoolIndex = "pool_index" - attrPSUIndex = "psu_index" - attrPSUStatus = "psu_status" - attrGetFailures = "get_failures" - attrRemoteFailures = "remote_failures" - attrThreshold = "threshold" -) - -// Cause summary message constants -const ( - causeHighDeviceHWError = "High device-wide hardware error rate" - causeElevatedDeviceHWError = "Elevated device-wide hardware error rate" - causeHighShareRejection = "High share rejection rate" - causeElevatedShareRejection = "Elevated share rejection rate" - causeHighStaleShares = "High stale share count" - causePoolNotAlive = "Pool connection not alive" - causeHighPoolGetFailures = "High pool get failure count" - causeHighPoolRemoteFailures = "High pool remote failure count" -) - -// Impact and action message constants -const ( - impactReducedHashrate = "Reduced mining hashrate and revenue" - impactMiningMayStop = "Mining may stop to prevent hardware damage" - impactHashboardOffline = "Hashboard offline, reduced mining capacity" - impactPoolConnectivity = "Unable to submit shares to pool" - actionCheckCooling = "Check cooling system, fans, and airflow" - actionCheckHashboard = "Check hashboard connections and power" - actionCheckPoolConfig = "Verify pool configuration and network connectivity" - actionMonitorPerformance = "Monitor device performance" -) - -// Helper functions to create error structures with common fields - -// createTemperatureError creates a temperature-related error with common fields -func createTemperatureError(severity sdkerrors.Severity, boardIndex int, temp float64, threshold float64, deviceID string, componentID string, now time.Time, extraAttrs map[string]string) sdkerrors.DeviceError { - var errorType sdkerrors.MinerError - var causeSummary, action, impact string - - if temp < tempUnderheatMinorCelsius { - errorType = sdkerrors.HashboardASICUnderTemperature - causeSummary = "Hashboard temperature too low" - action = "Check ambient temperature conditions" - impact = impactReducedHashrate - } else { - errorType = sdkerrors.HashboardOverTemperature - if severity == sdkerrors.SeverityCritical { - causeSummary = "Hashboard critically overheating" - impact = impactMiningMayStop - } else { - causeSummary = "Hashboard running hot" - impact = impactReducedHashrate - } - action = actionCheckCooling - } - - attrs := map[string]string{ - attrTempCelsius: fmt.Sprintf("%.1f", temp), - attrThresholdCelsius: fmt.Sprintf("%.0f", threshold), - } - for k, v := range extraAttrs { - attrs[k] = v - } - - return sdkerrors.DeviceError{ - MinerError: errorType, - Severity: severity, - Summary: fmt.Sprintf("Hashboard %d temperature %.1f°C %s threshold (%.0f°C)", boardIndex, temp, getSeverityVerb(severity, temp < tempUnderheatMinorCelsius), threshold), - CauseSummary: causeSummary, - RecommendedAction: action, - Impact: impact, - ComponentType: sdkerrors.ComponentTypeHashBoard, - ComponentID: &componentID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: attrs, - } -} - -// getSeverityVerb returns the appropriate verb for temperature errors -func getSeverityVerb(severity sdkerrors.Severity, isUnder bool) string { - if isUnder { - return "below minimum" - } - if severity == sdkerrors.SeverityCritical { - return "exceeds critical" - } - return "exceeds warning" -} - -// createHashboardStatusError creates a hashboard status error with common fields -func createHashboardStatusError(severity sdkerrors.Severity, boardIndex int, status string, deviceID string, componentID string, now time.Time, extraAttrs map[string]string) sdkerrors.DeviceError { - var errorType sdkerrors.MinerError - var summary, causeSummary, impact string - - if status == statusNotHashing { - errorType = sdkerrors.HashrateBelowTarget - summary = fmt.Sprintf("Hashboard %d is not producing hashrate", boardIndex) - causeSummary = "Hashboard not hashing" - } else if status == statusDisabled { - errorType = sdkerrors.HashboardNotPresent - summary = fmt.Sprintf("Hashboard %d is disabled", boardIndex) - causeSummary = "Hashboard disabled" - } else { - // Communication lost - include actual status if available in vendor attrs - errorType = sdkerrors.ASICChainCommunicationLost - actualStatus := extraAttrs[attrStatus] - if actualStatus != "" && actualStatus != hashboardStatusAlive { - summary = fmt.Sprintf("Hashboard %d status is '%s' (expected '%s')", boardIndex, actualStatus, hashboardStatusAlive) - } else { - summary = fmt.Sprintf("Hashboard %d communication lost", boardIndex) - } - causeSummary = "Hashboard communication lost" - } - - impact = impactHashboardOffline - - return sdkerrors.DeviceError{ - MinerError: errorType, - Severity: severity, - Summary: summary, - CauseSummary: causeSummary, - RecommendedAction: actionCheckHashboard, - Impact: impact, - ComponentType: sdkerrors.ComponentTypeHashBoard, - ComponentID: &componentID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: extraAttrs, - } -} - -// createHardwareErrorError creates a hardware error with common fields -func createHardwareErrorError(severity sdkerrors.Severity, boardIndex int, hwPercent float64, hwCount int, threshold string, deviceID string, componentID string, now time.Time, extraAttrs map[string]string) sdkerrors.DeviceError { - var summary string - if hwPercent >= hwErrorPercentMinor { - summary = fmt.Sprintf("Hashboard %d has %.2f%% hardware error rate (threshold %s)", boardIndex, hwPercent, threshold) - } else { - summary = fmt.Sprintf("Hashboard %d has %d hardware errors (threshold %s)", boardIndex, hwCount, threshold) - } - - var causeSummary, action string - if severity == sdkerrors.SeverityMajor { - causeSummary = "High hardware error rate on hashboard" - action = actionCheckHashboard - } else { - causeSummary = "Elevated hardware error rate on hashboard" - action = actionMonitorPerformance - } - - attrs := map[string]string{ - attrHWErrorPercent: fmt.Sprintf("%.2f", hwPercent), - attrHWErrorCount: strconv.Itoa(hwCount), - "threshold_" + getThresholdType(hwPercent): threshold, - } - for k, v := range extraAttrs { - attrs[k] = v - } - - return sdkerrors.DeviceError{ - MinerError: sdkerrors.HashboardWarnCRCHigh, - Severity: severity, - Summary: summary, - CauseSummary: causeSummary, - RecommendedAction: action, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeHashBoard, - ComponentID: &componentID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: attrs, - } -} - -// getThresholdType returns "percent" or "count" based on error rate -func getThresholdType(hwPercent float64) string { - if hwPercent >= hwErrorPercentMinor { - return "percent" - } - return "count" -} - -// detectErrors aggregates all detected errors from RPC and Web API responses. -// Prioritizes stats.cgi data when available, falling back to RPC devs data. -func detectErrors(summary *rpc.SummaryResponse, devs *rpc.DevsResponse, pools *rpc.PoolsResponse, stats *web.StatsInfo, deviceID string, sleeping bool) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - now := time.Now() - - // Track if per-board HW errors were found to avoid duplicate summary-level errors - var perBoardHWErrors []sdkerrors.DeviceError - - // Detect errors from device/hashboard data - // Prefer stats API data when available, fall back to devs RPC - if stats != nil && len(stats.STATS) > 0 && len(stats.STATS[0].Chain) > 0 { - // Use stats.cgi data for temperature, hashboard status, and per-board errors - slog.Debug("Using stats.cgi API data for per-chain error detection", "deviceID", deviceID, "chainCount", len(stats.STATS[0].Chain)) - errors = append(errors, detectTemperatureErrorsFromStats(stats.STATS[0].Chain, deviceID, now)...) - errors = append(errors, detectHashboardStatusErrorsFromStats(stats.STATS[0].Chain, deviceID, now, sleeping)...) - errors = append(errors, detectFanErrorsFromStats(stats.STATS[0].Fan, stats.STATS[0].FanNum, deviceID, now)...) - errors = append(errors, detectPSUErrorsFromStats(stats.STATS[0].PSU, deviceID, now)...) - perBoardHWErrors = detectPerBoardHardwareErrorsFromStats(stats.STATS[0].Chain, deviceID, now) - errors = append(errors, perBoardHWErrors...) - } else if devs != nil && len(devs.Devs) > 0 { - // Fallback to RPC devs data - slog.Debug("Falling back to RPC devs API for per-board error detection", "deviceID", deviceID, "devCount", len(devs.Devs)) - errors = append(errors, detectTemperatureErrors(devs.Devs, deviceID, now)...) - errors = append(errors, detectHashboardStatusErrors(devs.Devs, deviceID, now, sleeping)...) - perBoardHWErrors = detectPerBoardHardwareErrors(devs.Devs, deviceID, now) - errors = append(errors, perBoardHWErrors...) - } - - // Detect errors from summary data - if summary != nil && len(summary.Summary) > 0 { - summaryInfo := &summary.Summary[0] - // Only add summary-level HW errors if no per-board HW errors exist - // to avoid duplicate reporting of the same underlying issue - if len(perBoardHWErrors) == 0 { - errors = append(errors, detectSummaryHardwareErrors(summaryInfo, deviceID, now)...) - } - errors = append(errors, detectShareRejectionErrors(summaryInfo, deviceID, now)...) - } - - // Detect errors from pool data - if pools != nil && len(pools.Pools) > 0 { - errors = append(errors, detectPoolErrors(pools.Pools, deviceID, now)...) - } - - return errors -} - -func detectFanErrorsFromStats(fanSpeeds []int, activeFanCount int, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - checkedFans := len(fanSpeeds) - if activeFanCount >= 0 && activeFanCount < checkedFans { - checkedFans = activeFanCount - } - - for i := range checkedFans { - rpm := fanSpeeds[i] - if rpm > 0 { - continue - } - - fanSlot := i + 1 - fanID := strconv.Itoa(fanSlot) - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.FanFailed, - Severity: sdkerrors.SeverityCritical, - Summary: fmt.Sprintf("Fan %d has stopped working", fanSlot), - CauseSummary: "Cooling fan stopped reporting RPM", - RecommendedAction: "Replace failed fan immediately", - Impact: "Miner will thermal throttle or shut down", - ComponentType: sdkerrors.ComponentTypeFan, - ComponentID: &fanID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrFanIndex: fanID, - attrFanRPM: strconv.Itoa(rpm), - }, - }) - } - - return errors -} - -func detectPSUErrorsFromStats(psu *web.PSUStats, deviceID string, now time.Time) []sdkerrors.DeviceError { - if psu == nil || strings.EqualFold(psu.Status, "ok") || psu.Status == "" { - return nil - } - - psuSlot := psu.Index + 1 - psuID := strconv.Itoa(psuSlot) - return []sdkerrors.DeviceError{ - { - MinerError: sdkerrors.PSUFaultGeneric, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("PSU %d status is '%s'", psuSlot, psu.Status), - CauseSummary: "Power supply reported a fault", - RecommendedAction: "Inspect PSU for damage or overheating", - Impact: "Power delivery may be compromised", - ComponentType: sdkerrors.ComponentTypePSU, - ComponentID: &psuID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrPSUIndex: psuID, - attrPSUStatus: psu.Status, - }, - }, - } -} - -// detectTemperatureErrors checks boards for temperature issues. -func detectTemperatureErrors(devs []rpc.DevInfo, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, dev := range devs { - temp := dev.GetTemperature() - if temp == 0 { - continue - } - - ascID := strconv.Itoa(dev.ASC) - vendorAttrs := map[string]string{attrASCIndex: ascID} - - if temp >= tempOverheatCriticalCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityCritical, dev.ASC, temp, tempOverheatCriticalCelsius, - deviceID, ascID, now, vendorAttrs, - )) - } else if temp >= tempOverheatMajorCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityMajor, dev.ASC, temp, tempOverheatMajorCelsius, - deviceID, ascID, now, vendorAttrs, - )) - } else if temp < tempUnderheatMinorCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityMinor, dev.ASC, temp, tempUnderheatMinorCelsius, - deviceID, ascID, now, vendorAttrs, - )) - } - } - - return errors -} - -// detectHashboardStatusErrors checks board status and communication. -func detectHashboardStatusErrors(devs []rpc.DevInfo, deviceID string, now time.Time, sleeping bool) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, dev := range devs { - ascID := strconv.Itoa(dev.ASC) - vendorAttrs := map[string]string{ - attrStatus: dev.Status, - attrEnabled: dev.Enabled, - attrASCIndex: ascID, - } - - // Check if board is not alive (communication lost) - if dev.Status != hashboardStatusAlive { - errors = append(errors, createHashboardStatusError( - sdkerrors.SeverityCritical, dev.ASC, statusCommunicationLost, - deviceID, ascID, now, vendorAttrs, - )) - continue - } - - // Check if board is disabled - if dev.Enabled != hashboardEnabledYes { - errors = append(errors, createHashboardStatusError( - sdkerrors.SeverityMajor, dev.ASC, statusDisabled, - deviceID, ascID, now, vendorAttrs, - )) - continue - } - - // Check if board is alive but not hashing - if !sleeping && dev.MHSAv == 0 { - vendorAttrs[attrMHSAv] = fmt.Sprintf("%.2f", dev.MHSAv) - errors = append(errors, createHashboardStatusError( - sdkerrors.SeverityMajor, dev.ASC, statusNotHashing, - deviceID, ascID, now, vendorAttrs, - )) - } - } - - return errors -} - -// detectPerBoardHardwareErrors checks per-board hardware error rates. -func detectPerBoardHardwareErrors(devs []rpc.DevInfo, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, dev := range devs { - ascID := strconv.Itoa(dev.ASC) - vendorAttrs := map[string]string{attrASCIndex: ascID} - - // Check hardware error percentage - if dev.DeviceHardwarePercent >= hwErrorPercentMajor { - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMajor, dev.ASC, dev.DeviceHardwarePercent, - int(dev.HardwareErrors), fmt.Sprintf("%.1f%%", hwErrorPercentMajor), - deviceID, ascID, now, vendorAttrs, - )) - } else if dev.DeviceHardwarePercent >= hwErrorPercentMinor { - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMinor, dev.ASC, dev.DeviceHardwarePercent, - int(dev.HardwareErrors), fmt.Sprintf("%.1f%%", hwErrorPercentMinor), - deviceID, ascID, now, vendorAttrs, - )) - } else if dev.HardwareErrors >= hwErrorCountMinor { - // Also check absolute count for boards with low total work - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMinor, dev.ASC, dev.DeviceHardwarePercent, - int(dev.HardwareErrors), strconv.FormatInt(hwErrorCountMinor, 10), - deviceID, ascID, now, vendorAttrs, - )) - } - } - - return errors -} - -// detectSummaryHardwareErrors checks device-level hardware error rates from summary. -func detectSummaryHardwareErrors(summary *rpc.SummaryInfo, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - // Check device-wide hardware error percentage - if summary.DeviceHardwarePercent >= hwErrorPercentMajor { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.HashboardWarnCRCHigh, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("Device has %.2f%% overall hardware error rate (threshold %.1f%%)", summary.DeviceHardwarePercent, hwErrorPercentMajor), - CauseSummary: causeHighDeviceHWError, - RecommendedAction: actionCheckHashboard, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeControlBoard, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrHWErrorPercent: fmt.Sprintf("%.2f", summary.DeviceHardwarePercent), - attrHWErrorCount: strconv.FormatInt(summary.HardwareErrors, 10), - attrThresholdPercent: fmt.Sprintf("%.1f", hwErrorPercentMajor), - }, - }) - } else if summary.DeviceHardwarePercent >= hwErrorPercentMinor { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.HashboardWarnCRCHigh, - Severity: sdkerrors.SeverityMinor, - Summary: fmt.Sprintf("Device has %.2f%% overall hardware error rate (threshold %.1f%%)", summary.DeviceHardwarePercent, hwErrorPercentMinor), - CauseSummary: causeElevatedDeviceHWError, - RecommendedAction: actionMonitorPerformance, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeControlBoard, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrHWErrorPercent: fmt.Sprintf("%.2f", summary.DeviceHardwarePercent), - attrHWErrorCount: strconv.FormatInt(summary.HardwareErrors, 10), - attrThresholdPercent: fmt.Sprintf("%.1f", hwErrorPercentMinor), - }, - }) - } - - return errors -} - -// detectShareRejectionErrors checks share rejection and stale rates. -func detectShareRejectionErrors(summary *rpc.SummaryInfo, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - // Check rejection percentage - if summary.DeviceRejectedPercent >= rejectedPercentMajor { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.HashrateBelowTarget, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("Device has %.2f%% share rejection rate (threshold %.1f%%)", summary.DeviceRejectedPercent, rejectedPercentMajor), - CauseSummary: causeHighShareRejection, - RecommendedAction: actionCheckPoolConfig, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeControlBoard, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrRejectedPercent: fmt.Sprintf("%.2f", summary.DeviceRejectedPercent), - attrRejectedCount: strconv.FormatInt(summary.Rejected, 10), - attrAcceptedCount: strconv.FormatInt(summary.Accepted, 10), - attrThresholdPercent: fmt.Sprintf("%.1f", rejectedPercentMajor), - }, - }) - } else if summary.DeviceRejectedPercent >= rejectedPercentMinor { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.HashrateBelowTarget, - Severity: sdkerrors.SeverityMinor, - Summary: fmt.Sprintf("Device has %.2f%% share rejection rate (threshold %.1f%%)", summary.DeviceRejectedPercent, rejectedPercentMinor), - CauseSummary: causeElevatedShareRejection, - RecommendedAction: actionCheckPoolConfig, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeControlBoard, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrRejectedPercent: fmt.Sprintf("%.2f", summary.DeviceRejectedPercent), - attrRejectedCount: strconv.FormatInt(summary.Rejected, 10), - attrAcceptedCount: strconv.FormatInt(summary.Accepted, 10), - attrThresholdPercent: fmt.Sprintf("%.1f", rejectedPercentMinor), - }, - }) - } - - // Check stale shares - if summary.Stale >= staleSharesThreshold { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.HashrateBelowTarget, - Severity: sdkerrors.SeverityMinor, - Summary: fmt.Sprintf("Device has %d stale shares (threshold %d)", summary.Stale, staleSharesThreshold), - CauseSummary: causeHighStaleShares, - RecommendedAction: actionCheckPoolConfig, - Impact: impactReducedHashrate, - ComponentType: sdkerrors.ComponentTypeControlBoard, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrStaleCount: strconv.FormatInt(summary.Stale, 10), - attrThresholdCount: strconv.FormatInt(staleSharesThreshold, 10), - attrPoolStalePercent: fmt.Sprintf("%.2f", summary.PoolStalePercent), - }, - }) - } - - return errors -} - -// detectPoolErrors checks pool connectivity issues. -// Only reports errors if ALL pools are not working (to avoid false alarms when failover pools exist). -func detectPoolErrors(pools []rpc.PoolInfo, deviceID string, now time.Time) []sdkerrors.DeviceError { - if len(pools) == 0 { - return nil - } - - // First pass: check if at least one pool is working - hasWorkingPool := false - for _, pool := range pools { - if pool.Status == poolStatusAlive && - pool.GetFailures < poolFailuresThreshold && - pool.RemoteFailures < poolFailuresThreshold { - hasWorkingPool = true - break - } - } - - // If any pool is working, don't report errors (failover is functioning) - if hasWorkingPool { - return nil - } - - // All pools are experiencing issues - report errors for each problematic pool - var errors []sdkerrors.DeviceError - - for _, pool := range pools { - poolID := strconv.Itoa(pool.Pool) - - // Check pool status - if pool.Status != poolStatusAlive { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.VendorErrorUnmapped, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("Pool %d (%s) status is '%s' (expected '%s')", pool.Pool, pool.URL, pool.Status, poolStatusAlive), - CauseSummary: causePoolNotAlive, - RecommendedAction: actionCheckPoolConfig, - Impact: impactPoolConnectivity, - ComponentType: sdkerrors.ComponentTypeUnspecified, - ComponentID: &poolID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrPoolURL: pool.URL, - attrPoolStatus: pool.Status, - attrPoolIndex: poolID, - }, - }) - continue - } - - // Check get failures - if pool.GetFailures >= poolFailuresThreshold { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.VendorErrorUnmapped, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("Pool %d (%s) has %d get failures (threshold %d)", pool.Pool, pool.URL, pool.GetFailures, poolFailuresThreshold), - CauseSummary: causeHighPoolGetFailures, - RecommendedAction: actionCheckPoolConfig, - Impact: impactPoolConnectivity, - ComponentType: sdkerrors.ComponentTypeUnspecified, - ComponentID: &poolID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrPoolURL: pool.URL, - attrGetFailures: strconv.FormatInt(pool.GetFailures, 10), - attrRemoteFailures: strconv.FormatInt(pool.RemoteFailures, 10), - attrThreshold: strconv.FormatInt(poolFailuresThreshold, 10), - attrPoolIndex: poolID, - }, - }) - } - - // Check remote failures - if pool.RemoteFailures >= poolFailuresThreshold { - errors = append(errors, sdkerrors.DeviceError{ - MinerError: sdkerrors.VendorErrorUnmapped, - Severity: sdkerrors.SeverityMajor, - Summary: fmt.Sprintf("Pool %d (%s) has %d remote failures (threshold %d)", pool.Pool, pool.URL, pool.RemoteFailures, poolFailuresThreshold), - CauseSummary: causeHighPoolRemoteFailures, - RecommendedAction: actionCheckPoolConfig, - Impact: impactPoolConnectivity, - ComponentType: sdkerrors.ComponentTypeUnspecified, - ComponentID: &poolID, - FirstSeenAt: now, - LastSeenAt: now, - DeviceID: deviceID, - VendorAttributes: map[string]string{ - attrPoolURL: pool.URL, - attrGetFailures: strconv.FormatInt(pool.GetFailures, 10), - attrRemoteFailures: strconv.FormatInt(pool.RemoteFailures, 10), - attrThreshold: strconv.FormatInt(poolFailuresThreshold, 10), - attrPoolIndex: poolID, - }, - }) - } - } - - return errors -} - -// Stats API-based error detection functions -// These functions use the stats.cgi Web API data for more accurate per-chain telemetry - -// detectTemperatureErrorsFromStats checks chains for temperature issues using stats API data. -func detectTemperatureErrorsFromStats(chains []web.ChainStats, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, chain := range chains { - // Calculate max temperature from the temp_chip array - if len(chain.TempChip) == 0 { - continue - } - - var maxTemp float64 - for _, temp := range chain.TempChip { - if temp > maxTemp { - maxTemp = temp - } - } - - chainID := strconv.Itoa(chain.Index) - vendorAttrs := map[string]string{ - attrChainIndex: chainID, - attrSerialNumber: chain.SN, - } - - if maxTemp >= tempOverheatCriticalCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityCritical, chain.Index, maxTemp, tempOverheatCriticalCelsius, - deviceID, chainID, now, vendorAttrs, - )) - } else if maxTemp >= tempOverheatMajorCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityMajor, chain.Index, maxTemp, tempOverheatMajorCelsius, - deviceID, chainID, now, vendorAttrs, - )) - } else if maxTemp < tempUnderheatMinorCelsius { - errors = append(errors, createTemperatureError( - sdkerrors.SeverityMinor, chain.Index, maxTemp, tempUnderheatMinorCelsius, - deviceID, chainID, now, vendorAttrs, - )) - } - } - - return errors -} - -// detectHashboardStatusErrorsFromStats checks chain status using stats API data. -func detectHashboardStatusErrorsFromStats(chains []web.ChainStats, deviceID string, now time.Time, sleeping bool) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, chain := range chains { - chainID := strconv.Itoa(chain.Index) - - // Check if chain is not hashing (RateReal is 0 or negative) - if !sleeping && chain.RateReal <= 0 { - vendorAttrs := map[string]string{ - attrRateRealGHS: fmt.Sprintf("%.2f", chain.RateReal), - attrChainIndex: chainID, - attrSerialNumber: chain.SN, - } - // Only include RateIdeal if it's a valid positive value - if chain.RateIdeal > 0 { - vendorAttrs[attrRateIdealGHS] = fmt.Sprintf("%.2f", chain.RateIdeal) - } - - errors = append(errors, createHashboardStatusError( - sdkerrors.SeverityMajor, chain.Index, statusNotHashing, - deviceID, chainID, now, vendorAttrs, - )) - } - } - - return errors -} - -// detectPerBoardHardwareErrorsFromStats checks per-chain hardware error rates using stats API data. -func detectPerBoardHardwareErrorsFromStats(chains []web.ChainStats, deviceID string, now time.Time) []sdkerrors.DeviceError { - var errors []sdkerrors.DeviceError - - for _, chain := range chains { - chainID := strconv.Itoa(chain.Index) - vendorAttrs := map[string]string{ - attrChainIndex: chainID, - attrSerialNumber: chain.SN, - } - - // Check hardware error percentage (HWP field) - if chain.HWP >= hwErrorPercentMajor { - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMajor, chain.Index, chain.HWP, - chain.HW, fmt.Sprintf("%.1f%%", hwErrorPercentMajor), - deviceID, chainID, now, vendorAttrs, - )) - } else if chain.HWP >= hwErrorPercentMinor { - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMinor, chain.Index, chain.HWP, - chain.HW, fmt.Sprintf("%.1f%%", hwErrorPercentMinor), - deviceID, chainID, now, vendorAttrs, - )) - } else if chain.HW >= int(hwErrorCountMinor) { - // Also check absolute count for boards with low total work - errors = append(errors, createHardwareErrorError( - sdkerrors.SeverityMinor, chain.Index, chain.HWP, - chain.HW, strconv.FormatInt(hwErrorCountMinor, 10), - deviceID, chainID, now, vendorAttrs, - )) - } - } - - return errors -} diff --git a/plugin/antminer/internal/device/errors_test.go b/plugin/antminer/internal/device/errors_test.go deleted file mode 100644 index f665d3b363..0000000000 --- a/plugin/antminer/internal/device/errors_test.go +++ /dev/null @@ -1,1229 +0,0 @@ -package device - -import ( - "fmt" - "testing" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdkerrors "github.com/block/proto-fleet/server/sdk/v1/errors" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const testDeviceIDForErrors = "test-device-errors" - -// Helper function to create test devs response -func createTestDevsResponse(devs ...rpc.DevInfo) *rpc.DevsResponse { - return &rpc.DevsResponse{ - Devs: devs, - } -} - -// Helper function to create test summary response -func createTestSummaryResponse() *rpc.SummaryResponse { - return &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - HardwareErrors: 10, - DeviceHardwarePercent: 0.1, - }, - }, - } -} - -// Test: Critical temperature error -func TestDetectTemperatureErrors_Critical(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 98.5, // Above critical threshold (95°C) - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityCritical, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "98.5°C") - assert.Contains(t, errors[0].Summary, "critical") - assert.Equal(t, sdkerrors.ComponentTypeHashBoard, errors[0].ComponentType) - require.NotNil(t, errors[0].ComponentID) - assert.Equal(t, "0", *errors[0].ComponentID) - assert.Equal(t, "98.5", errors[0].VendorAttributes["temperature_celsius"]) -} - -// Test: Major temperature warning -func TestDetectTemperatureErrors_Major(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 1, - Status: "Alive", - Enabled: "Y", - Temperature: 88.0, // Above major threshold (85°C) but below critical - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "88.0°C") - assert.Equal(t, "1", *errors[0].ComponentID) -} - -// Test: Under-temperature warning -func TestDetectTemperatureErrors_Under(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: -5.0, // Below minimum threshold (0°C) - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardASICUnderTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) -} - -// Test: Temperature detection with Tenperature field (typo in firmware) -func TestDetectTemperatureErrors_WithTypo(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Tenperature: 96.0, // Using the typo field - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityCritical, errors[0].Severity) -} - -// Test: No errors for normal temperature -func TestDetectTemperatureErrors_NoErrors(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 65.0, // Normal temperature - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for normal temperature") -} - -// Test: Zero temperature is skipped -func TestDetectTemperatureErrors_ZeroTemperature(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 0, // Zero temperature should be skipped - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for zero temperature (skipped)") -} - -// Test: Multiple boards with different temperatures -func TestDetectTemperatureErrors_MultipleBoards(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 65.0, // Normal - MHSAv: 100000000, - }, - { - ASC: 1, - Status: "Alive", - Enabled: "Y", - Temperature: 90.0, // Major warning - MHSAv: 100000000, - }, - { - ASC: 2, - Status: "Alive", - Enabled: "Y", - Temperature: 96.0, // Critical - MHSAv: 100000000, - }, - } - - errors := detectTemperatureErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 2) - - // Find errors by component ID - var majorErr, criticalErr *sdkerrors.DeviceError - for i := range errors { - if *errors[i].ComponentID == "1" { - majorErr = &errors[i] - } else if *errors[i].ComponentID == "2" { - criticalErr = &errors[i] - } - } - - require.NotNil(t, majorErr, "Expected major error for board 1") - assert.Equal(t, sdkerrors.SeverityMajor, majorErr.Severity) - - require.NotNil(t, criticalErr, "Expected critical error for board 2") - assert.Equal(t, sdkerrors.SeverityCritical, criticalErr.Severity) -} - -// Test: Hashboard not alive (communication lost) -func TestDetectHashboardStatusErrors_NotAlive(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Dead", - Enabled: "Y", - }, - } - - errors := detectHashboardStatusErrors(devs, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.ASICChainCommunicationLost, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityCritical, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "Dead") - assert.Equal(t, "Dead", errors[0].VendorAttributes["status"]) -} - -// Test: Hashboard disabled -func TestDetectHashboardStatusErrors_Disabled(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "N", - }, - } - - errors := detectHashboardStatusErrors(devs, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardNotPresent, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) -} - -// Test: Hashboard alive but not hashing -func TestDetectHashboardStatusErrors_NotHashing(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - MHSAv: 0, // No hashrate - }, - } - - errors := detectHashboardStatusErrors(devs, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashrateBelowTarget, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "not producing hashrate") -} - -// Test: Healthy hashboard produces no errors -func TestDetectHashboardStatusErrors_Healthy(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - MHSAv: 100000000, // Healthy hashrate - }, - } - - errors := detectHashboardStatusErrors(devs, testDeviceIDForErrors, now, false) - - assert.Empty(t, errors, "Expected no errors for healthy hashboard") -} - -// Test: Multiple boards with different statuses -func TestDetectHashboardStatusErrors_MultipleBoards(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - MHSAv: 100000000, // Healthy - }, - { - ASC: 1, - Status: "Dead", // Communication lost - Enabled: "Y", - }, - { - ASC: 2, - Status: "Alive", - Enabled: "N", // Disabled - }, - } - - errors := detectHashboardStatusErrors(devs, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 2) - - // Find errors by component ID - var deadErr, disabledErr *sdkerrors.DeviceError - for i := range errors { - if *errors[i].ComponentID == "1" { - deadErr = &errors[i] - } else if *errors[i].ComponentID == "2" { - disabledErr = &errors[i] - } - } - - require.NotNil(t, deadErr, "Expected error for dead board 1") - assert.Equal(t, sdkerrors.ASICChainCommunicationLost, deadErr.MinerError) - - require.NotNil(t, disabledErr, "Expected error for disabled board 2") - assert.Equal(t, sdkerrors.HashboardNotPresent, disabledErr.MinerError) -} - -// Test: High hardware error percentage (major) -func TestDetectPerBoardHardwareErrors_PercentMajor(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 70.0, - MHSAv: 100000000, - DeviceHardwarePercent: 6.5, // Above 5% threshold - HardwareErrors: 5000, - }, - } - - errors := detectPerBoardHardwareErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "6.50%") -} - -// Test: Elevated hardware error percentage (minor) -func TestDetectPerBoardHardwareErrors_PercentMinor(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 70.0, - MHSAv: 100000000, - DeviceHardwarePercent: 2.5, // Between 1% and 5% - HardwareErrors: 500, - }, - } - - errors := detectPerBoardHardwareErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) -} - -// Test: High hardware error count (minor) -func TestDetectPerBoardHardwareErrors_Count(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 70.0, - MHSAv: 100000000, - DeviceHardwarePercent: 0.5, // Low percentage - HardwareErrors: 1500, // But high absolute count (>1000) - }, - } - - errors := detectPerBoardHardwareErrors(devs, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "1500") -} - -// Test: No hardware errors for healthy board -func TestDetectPerBoardHardwareErrors_NoErrors(t *testing.T) { - now := time.Now() - devs := []rpc.DevInfo{ - { - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 70.0, - MHSAv: 100000000, - DeviceHardwarePercent: 0.1, - HardwareErrors: 10, - }, - } - - errors := detectPerBoardHardwareErrors(devs, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for healthy board") -} - -// Test: Device-level hardware error from summary (major) -func TestDetectSummaryHardwareErrors_Major(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - HardwareErrors: 10000, - DeviceHardwarePercent: 7.5, // Above 5% threshold - } - - errors := detectSummaryHardwareErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Equal(t, sdkerrors.ComponentTypeControlBoard, errors[0].ComponentType) -} - -// Test: Device-level hardware error from summary (minor) -func TestDetectSummaryHardwareErrors_Minor(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - HardwareErrors: 1000, - DeviceHardwarePercent: 2.5, // Between 1% and 5% - } - - errors := detectSummaryHardwareErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) -} - -// Test: No summary hardware errors for healthy device -func TestDetectSummaryHardwareErrors_NoErrors(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - HardwareErrors: 10, - DeviceHardwarePercent: 0.1, - } - - errors := detectSummaryHardwareErrors(summary, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for healthy device") -} - -// Test: High share rejection rate (major) -func TestDetectShareRejectionErrors_Major(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - DeviceRejectedPercent: 12.5, // Above 10% threshold - Rejected: 125, - Accepted: 875, - } - - errors := detectShareRejectionErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashrateBelowTarget, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "12.50%") -} - -// Test: Elevated share rejection rate (minor) -func TestDetectShareRejectionErrors_Minor(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - DeviceRejectedPercent: 7.5, // Between 5% and 10% - Rejected: 75, - Accepted: 925, - } - - errors := detectShareRejectionErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashrateBelowTarget, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) -} - -// Test: High stale shares -func TestDetectShareRejectionErrors_Stale(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - DeviceRejectedPercent: 0.5, // Low rejection - Stale: 150, // Above 100 threshold - } - - errors := detectShareRejectionErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashrateBelowTarget, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "150 stale") -} - -// Test: Both rejection and stale issues -func TestDetectShareRejectionErrors_Multiple(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - DeviceRejectedPercent: 12.5, // Major rejection - Rejected: 125, - Accepted: 875, - Stale: 150, // Also high stale - } - - errors := detectShareRejectionErrors(summary, testDeviceIDForErrors, now) - - require.Len(t, errors, 2) -} - -// Test: No share rejection errors for healthy device -func TestDetectShareRejectionErrors_NoErrors(t *testing.T) { - now := time.Now() - summary := &rpc.SummaryInfo{ - DeviceRejectedPercent: 0.5, - Rejected: 5, - Accepted: 995, - Stale: 10, - } - - errors := detectShareRejectionErrors(summary, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for healthy device") -} - -// Test: Pool not alive -func TestDetectPoolErrors_NotAlive(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool.example.com:3333", - Status: "Dead", - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.VendorErrorUnmapped, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "Dead") - assert.Contains(t, errors[0].Summary, "pool.example.com") -} - -// Test: Pool get failures -func TestDetectPoolErrors_GetFailures(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool.example.com:3333", - Status: "Alive", - GetFailures: 15, // >10 threshold - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.VendorErrorUnmapped, errors[0].MinerError) - assert.Contains(t, errors[0].Summary, "15 get failures") -} - -// Test: Pool remote failures -func TestDetectPoolErrors_RemoteFailures(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool.example.com:3333", - Status: "Alive", - RemoteFailures: 20, // >10 threshold - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.VendorErrorUnmapped, errors[0].MinerError) - assert.Contains(t, errors[0].Summary, "20 remote failures") -} - -// Test: Multiple pools with different issues - should not error if one pool is working -func TestDetectPoolErrors_MultiplePools(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool1.example.com:3333", - Status: "Alive", - }, - { - Pool: 1, - URL: "stratum+tcp://pool2.example.com:3333", - Status: "Dead", - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - // Should not report errors because pool 0 is working (failover is functioning) - assert.Empty(t, errors, "Expected no errors when at least one pool is working") -} - -// Test: All pools down - should report errors for all -func TestDetectPoolErrors_AllPoolsDown(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool1.example.com:3333", - Status: "Dead", - }, - { - Pool: 1, - URL: "stratum+tcp://pool2.example.com:3333", - Status: "Dead", - }, - { - Pool: 2, - URL: "stratum+tcp://pool3.example.com:3333", - Status: "Dead", - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - // Should report errors for all pools since none are working - require.Len(t, errors, 3, "Expected errors for all pools when all are down") - assert.Equal(t, "0", *errors[0].ComponentID) - assert.Equal(t, "1", *errors[1].ComponentID) - assert.Equal(t, "2", *errors[2].ComponentID) -} - -// Test: All pools have high failures - should report errors -func TestDetectPoolErrors_AllPoolsHighFailures(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool1.example.com:3333", - Status: "Alive", - GetFailures: 15, // >10 threshold - }, - { - Pool: 1, - URL: "stratum+tcp://pool2.example.com:3333", - Status: "Alive", - RemoteFailures: 20, // >10 threshold - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - // Should report errors for all pools since all have high failures - require.Len(t, errors, 2, "Expected errors for all pools when all have failures") - assert.Contains(t, errors[0].Summary, "get failures") - assert.Contains(t, errors[1].Summary, "remote failures") -} - -// Test: No pool errors for healthy pools -func TestDetectPoolErrors_NoErrors(t *testing.T) { - now := time.Now() - pools := []rpc.PoolInfo{ - { - Pool: 0, - URL: "stratum+tcp://pool.example.com:3333", - Status: "Alive", - GetFailures: 5, - RemoteFailures: 3, - }, - } - - errors := detectPoolErrors(pools, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Expected no errors for healthy pool") -} - -// ============================================================================ -// detectErrors aggregation tests -// ============================================================================ - -// Helper functions for aggregation tests -func healthyDevInfo(asc int) rpc.DevInfo { - return rpc.DevInfo{ - ASC: asc, - Status: "Alive", - Enabled: "Y", - Temperature: 65.0, - MHSAv: 100000000, // 100 TH/s - HardwareErrors: 10, - DeviceHardwarePercent: 0.1, - } -} - -func healthyPoolInfo(pool int, url string) rpc.PoolInfo { - return rpc.PoolInfo{ - Pool: pool, - URL: url, - Status: "Alive", - GetFailures: 0, - RemoteFailures: 0, - } -} - -func createTestPoolsResponse(pools ...rpc.PoolInfo) *rpc.PoolsResponse { - return &rpc.PoolsResponse{ - Pools: pools, - } -} - -func createFullTestSummaryResponse(hwErrors int64, hwPercent, rejectedPercent float64, stale int64) *rpc.SummaryResponse { - return &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - HardwareErrors: hwErrors, - DeviceHardwarePercent: hwPercent, - DeviceRejectedPercent: rejectedPercent, - Stale: stale, - Accepted: 1000, - Rejected: 10, - }, - }, - } -} - -// Test: No errors when all metrics are healthy -func TestDetectErrors_NoErrors(t *testing.T) { - summary := createFullTestSummaryResponse(10, 0.1, 0.5, 5) - devs := createTestDevsResponse( - healthyDevInfo(0), - healthyDevInfo(1), - healthyDevInfo(2), - ) - pools := createTestPoolsResponse( - healthyPoolInfo(0, "stratum+tcp://pool1.example.com:3333"), - ) - - errors := detectErrors(summary, devs, pools, nil, testDeviceIDForErrors, false) - - assert.Empty(t, errors, "Expected no errors for healthy device") -} - -// Test: Multiple concurrent errors -func TestDetectErrors_MultipleConcurrentErrors(t *testing.T) { - summary := createFullTestSummaryResponse(10, 0.1, 12.5, 5) // High rejection - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 96.0, // Critical temp - MHSAv: 100000000, - }, - rpc.DevInfo{ - ASC: 1, - Status: "Dead", // Communication lost - Enabled: "Y", - }, - ) - pools := createTestPoolsResponse( - rpc.PoolInfo{ - Pool: 0, - URL: "stratum+tcp://pool.example.com:3333", - Status: "Alive", - GetFailures: 15, // High failures - }, - ) - - errors := detectErrors(summary, devs, pools, nil, testDeviceIDForErrors, false) - - // Should have: 1 temp error, 1 comm lost, 1 rejection, 1 pool failure - require.Len(t, errors, 4) - - // Verify we have each type of error - var hasTemp, hasComm, hasRejection, hasPool bool - for _, err := range errors { - switch err.MinerError { - case sdkerrors.HashboardOverTemperature: - hasTemp = true - case sdkerrors.ASICChainCommunicationLost: - hasComm = true - case sdkerrors.HashrateBelowTarget: - hasRejection = true - case sdkerrors.VendorErrorUnmapped: - hasPool = true - } - } - assert.True(t, hasTemp, "Expected temperature error") - assert.True(t, hasComm, "Expected communication error") - assert.True(t, hasRejection, "Expected rejection error") - assert.True(t, hasPool, "Expected pool error") -} - -// Test: Empty responses -func TestDetectErrors_EmptyResponses(t *testing.T) { - errors := detectErrors(nil, nil, nil, nil, testDeviceIDForErrors, false) - assert.Empty(t, errors) - - errors = detectErrors(&rpc.SummaryResponse{}, &rpc.DevsResponse{}, &rpc.PoolsResponse{}, nil, testDeviceIDForErrors, false) - assert.Empty(t, errors) -} - -// Test: Timestamps are set correctly -func TestDetectErrors_Timestamps(t *testing.T) { - beforeTest := time.Now() - - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Dead", - Enabled: "Y", - Temperature: 70.0, - }, - ) - - errors := detectErrors(nil, devs, nil, nil, testDeviceIDForErrors, false) - - afterTest := time.Now() - - require.Len(t, errors, 1) - assert.True(t, errors[0].FirstSeenAt.After(beforeTest) || errors[0].FirstSeenAt.Equal(beforeTest)) - assert.True(t, errors[0].LastSeenAt.Before(afterTest) || errors[0].LastSeenAt.Equal(afterTest)) - assert.Equal(t, errors[0].FirstSeenAt, errors[0].LastSeenAt) -} - -// Test: DeviceID is set correctly -func TestDetectErrors_DeviceID(t *testing.T) { - customDeviceID := "custom-device-123" - - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Dead", - Enabled: "Y", - }, - ) - - errors := detectErrors(nil, devs, nil, nil, customDeviceID, false) - - require.Len(t, errors, 1) - assert.Equal(t, customDeviceID, errors[0].DeviceID) -} - -// ============================================================================ -// Stats API-based Error Detection Tests (with fallback to RPC) -// ============================================================================ - -// Helper function to create test ChainStats for stats API tests -func createTestChainStats(index int, tempChip []float64, rateReal float64, hwp float64, hw int) web.ChainStats { - return web.ChainStats{ - Index: index, - TempChip: tempChip, - RateReal: rateReal, - RateIdeal: rateReal * 1.1, // Ideal is typically slightly higher - HWP: hwp, - HW: hw, - SN: fmt.Sprintf("SN-CHAIN-%d", index), - } -} - -func createTestPSUStats(index int, status string) *web.PSUStats { - return &web.PSUStats{ - Index: index, - Status: status, - } -} - -// Test: Stats API temperature detection - Critical -func TestDetectTemperatureErrorsFromStats_Critical(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{96.0, 97.0, 95.5, 96.5}, 14000.0, 0.1, 10), - } - - errors := detectTemperatureErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityCritical, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "97.0°C") // Max temp from array - assert.Contains(t, errors[0].VendorAttributes, "chain_index") - assert.Contains(t, errors[0].VendorAttributes, "serial_number") - assert.Equal(t, "SN-CHAIN-0", errors[0].VendorAttributes["serial_number"]) -} - -// Test: Stats API temperature detection - Major -func TestDetectTemperatureErrorsFromStats_Major(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(1, []float64{86.0, 87.0, 85.5, 86.5}, 14000.0, 0.1, 10), - } - - errors := detectTemperatureErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "87.0°C") -} - -// Test: Stats API temperature detection - Multiple chains -func TestDetectTemperatureErrorsFromStats_MultipleChains(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{96.0, 97.0}, 14000.0, 0.1, 10), // Critical - createTestChainStats(1, []float64{70.0, 72.0}, 14000.0, 0.1, 10), // OK - createTestChainStats(2, []float64{86.0, 87.0}, 14000.0, 0.1, 10), // Major - } - - errors := detectTemperatureErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 2) - // Should have one critical and one major - severities := []sdkerrors.Severity{errors[0].Severity, errors[1].Severity} - assert.Contains(t, severities, sdkerrors.SeverityCritical) - assert.Contains(t, severities, sdkerrors.SeverityMajor) -} - -// Test: Stats API temperature detection - Empty temp array -func TestDetectTemperatureErrorsFromStats_EmptyTempArray(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{}, 14000.0, 0.1, 10), - } - - errors := detectTemperatureErrorsFromStats(chains, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Should not detect errors when temp array is empty") -} - -// Test: Stats API temperature detection - All invalid temperatures -func TestDetectTemperatureErrorsFromStats_InvalidTemps(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{-300.0, -400.0}, 14000.0, 0.1, 10), - } - - errors := detectTemperatureErrorsFromStats(chains, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Should not detect errors when all temps are invalid") -} - -// Test: Stats API hashboard status - Not hashing -func TestDetectHashboardStatusErrorsFromStats_NotHashing(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0, 72.0}, 0.0, 0.0, 0), // RateReal = 0 - } - - errors := detectHashboardStatusErrorsFromStats(chains, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashrateBelowTarget, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "not producing hashrate") - assert.Contains(t, errors[0].VendorAttributes, "rate_real_ghs") - assert.Contains(t, errors[0].VendorAttributes, "serial_number") - // rate_ideal_ghs is not included when RateReal is 0 (validation skips invalid values) - assert.NotContains(t, errors[0].VendorAttributes, "rate_ideal_ghs") -} - -// Test: Stats API hashboard status - Multiple chains with issues -func TestDetectHashboardStatusErrorsFromStats_MultipleChains(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 0.0, 0.0, 0), // Not hashing - createTestChainStats(1, []float64{72.0}, 14000.0, 0.1, 10), // OK - createTestChainStats(2, []float64{71.0}, -1.0, 0.0, 0), // Negative rate - } - - errors := detectHashboardStatusErrorsFromStats(chains, testDeviceIDForErrors, now, false) - - require.Len(t, errors, 2, "Should detect 2 non-hashing chains") -} - -// Test: Stats API hashboard status - All healthy -func TestDetectHashboardStatusErrorsFromStats_Healthy(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 14000.0, 0.1, 10), - createTestChainStats(1, []float64{72.0}, 13500.0, 0.1, 10), - } - - errors := detectHashboardStatusErrorsFromStats(chains, testDeviceIDForErrors, now, false) - - assert.Empty(t, errors, "Should not detect errors for healthy chains") -} - -func TestDetectFanErrorsFromStats_Failed(t *testing.T) { - now := time.Now() - - errors := detectFanErrorsFromStats([]int{0, 7050, 6980, 7020}, 4, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.FanFailed, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityCritical, errors[0].Severity) - assert.Equal(t, sdkerrors.ComponentTypeFan, errors[0].ComponentType) - require.NotNil(t, errors[0].ComponentID) - assert.Equal(t, "1", *errors[0].ComponentID) - assert.Contains(t, errors[0].Summary, "Fan 1") - assert.Equal(t, "1", errors[0].VendorAttributes["fan_index"]) - assert.Equal(t, "0", errors[0].VendorAttributes["fan_rpm"]) -} - -func TestDetectFanErrorsFromStats_Healthy(t *testing.T) { - now := time.Now() - - errors := detectFanErrorsFromStats([]int{7000, 7050, 6980, 7020}, 4, testDeviceIDForErrors, now) - - assert.Empty(t, errors) -} - -func TestDetectFanErrorsFromStats_IgnoresInactivePlaceholderSlots(t *testing.T) { - now := time.Now() - - errors := detectFanErrorsFromStats([]int{7000, 7050, 0, 0}, 2, testDeviceIDForErrors, now) - - assert.Empty(t, errors) -} - -func TestDetectFanErrorsFromStats_SkipsChecksWhenNoActiveFansReported(t *testing.T) { - now := time.Now() - - errors := detectFanErrorsFromStats([]int{0, 0, 0, 0}, 0, testDeviceIDForErrors, now) - - assert.Empty(t, errors) -} - -func TestDetectPSUErrorsFromStats_Fault(t *testing.T) { - now := time.Now() - - errors := detectPSUErrorsFromStats(createTestPSUStats(0, "fault"), testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.PSUFaultGeneric, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Equal(t, sdkerrors.ComponentTypePSU, errors[0].ComponentType) - require.NotNil(t, errors[0].ComponentID) - assert.Equal(t, "1", *errors[0].ComponentID) - assert.Contains(t, errors[0].Summary, "PSU 1") - assert.Equal(t, "1", errors[0].VendorAttributes["psu_index"]) - assert.Equal(t, "fault", errors[0].VendorAttributes["psu_status"]) -} - -func TestDetectPSUErrorsFromStats_Healthy(t *testing.T) { - now := time.Now() - - errors := detectPSUErrorsFromStats(createTestPSUStats(0, "ok"), testDeviceIDForErrors, now) - - assert.Empty(t, errors) -} - -// Test: Stats API hardware errors - Major percentage -func TestDetectPerBoardHardwareErrorsFromStats_PercentMajor(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 14000.0, 6.0, 5000), // HWP = 6% > 5% threshold - } - - errors := detectPerBoardHardwareErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMajor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "6.00%") - assert.Contains(t, errors[0].VendorAttributes, "hw_error_percent") - assert.Contains(t, errors[0].VendorAttributes, "hw_error_count") - assert.Equal(t, "5000", errors[0].VendorAttributes["hw_error_count"]) -} - -// Test: Stats API hardware errors - Minor percentage -func TestDetectPerBoardHardwareErrorsFromStats_PercentMinor(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 14000.0, 2.0, 1500), // HWP = 2% between 1-5% - } - - errors := detectPerBoardHardwareErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.HashboardWarnCRCHigh, errors[0].MinerError) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "2.00%") -} - -// Test: Stats API hardware errors - High count threshold -func TestDetectPerBoardHardwareErrorsFromStats_Count(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 14000.0, 0.5, 1200), // Low % but high count - } - - errors := detectPerBoardHardwareErrorsFromStats(chains, testDeviceIDForErrors, now) - - require.Len(t, errors, 1) - assert.Equal(t, sdkerrors.SeverityMinor, errors[0].Severity) - assert.Contains(t, errors[0].Summary, "1200 hardware errors") -} - -// Test: Stats API hardware errors - No errors -func TestDetectPerBoardHardwareErrorsFromStats_NoErrors(t *testing.T) { - now := time.Now() - chains := []web.ChainStats{ - createTestChainStats(0, []float64{70.0}, 14000.0, 0.1, 10), - createTestChainStats(1, []float64{72.0}, 13500.0, 0.2, 20), - } - - errors := detectPerBoardHardwareErrorsFromStats(chains, testDeviceIDForErrors, now) - - assert.Empty(t, errors, "Should not detect errors with low HW error rates") -} - -// ============================================================================ -// Fallback Behavior Tests -// ============================================================================ - -// Test: Fallback from stats to RPC when stats is nil -func TestDetectErrors_FallbackToRPC_StatsNil(t *testing.T) { - summary := createTestSummaryResponse() - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 96.0, // Critical temp - MHSAv: 100000000, - }, - ) - - // Pass nil for stats - should use RPC devs data - errors := detectErrors(summary, devs, nil, nil, testDeviceIDForErrors, false) - - require.Len(t, errors, 1, "Should detect temperature error from RPC devs") - assert.Equal(t, sdkerrors.HashboardOverTemperature, errors[0].MinerError) - // Check that vendor attributes use RPC field name (asc_index not chain_index) - assert.Contains(t, errors[0].VendorAttributes, "asc_index") - assert.NotContains(t, errors[0].VendorAttributes, "chain_index") -} - -// Test: Fallback from stats to RPC when stats has no chains -func TestDetectErrors_FallbackToRPC_EmptyChains(t *testing.T) { - summary := createTestSummaryResponse() - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 96.0, - MHSAv: 100000000, - DeviceHardwarePercent: 6.0, - HardwareErrors: 5000, - }, - ) - stats := &web.StatsInfo{ - STATS: []web.StatsData{ - {Chain: []web.ChainStats{}}, // Empty chains - }, - } - - errors := detectErrors(summary, devs, nil, stats, testDeviceIDForErrors, false) - - require.Len(t, errors, 2, "Should detect temp + HW errors from RPC devs") - assert.Contains(t, errors[0].VendorAttributes, "asc_index") -} - -// Test: Prefer stats over RPC when stats is available -func TestDetectErrors_PreferStats_WhenAvailable(t *testing.T) { - summary := createTestSummaryResponse() - // RPC devs has one error - devs := createTestDevsResponse( - rpc.DevInfo{ - ASC: 0, - Status: "Alive", - Enabled: "Y", - Temperature: 96.0, // Would trigger error - MHSAv: 100000000, - }, - ) - // Stats has different (healthier) data - stats := &web.StatsInfo{ - STATS: []web.StatsData{ - { - Chain: []web.ChainStats{ - createTestChainStats(0, []float64{70.0, 72.0}, 14000.0, 0.1, 10), // Healthy - }, - }, - }, - } - - errors := detectErrors(summary, devs, nil, stats, testDeviceIDForErrors, false) - - // Should use stats data (healthy) not RPC devs data (overheating) - assert.Empty(t, errors, "Should use healthy stats data, not RPC data") -} - -// Test: Stats data takes precedence and uses chain_index -func TestDetectErrors_StatsDataUsesChainIndex(t *testing.T) { - summary := createTestSummaryResponse() - stats := &web.StatsInfo{ - STATS: []web.StatsData{ - { - Chain: []web.ChainStats{ - createTestChainStats(0, []float64{96.0, 97.0}, 14000.0, 0.1, 10), // Critical - }, - }, - }, - } - - errors := detectErrors(summary, nil, nil, stats, testDeviceIDForErrors, false) - - require.Len(t, errors, 1) - // Verify it uses stats-specific field name - assert.Contains(t, errors[0].VendorAttributes, "chain_index") - assert.Contains(t, errors[0].VendorAttributes, "serial_number") - assert.NotContains(t, errors[0].VendorAttributes, "asc_index") -} diff --git a/plugin/antminer/internal/driver/driver.go b/plugin/antminer/internal/driver/driver.go deleted file mode 100644 index 376eedf52f..0000000000 --- a/plugin/antminer/internal/driver/driver.go +++ /dev/null @@ -1,409 +0,0 @@ -// Package driver implements the Fleet SDK Driver interface for Antminer devices. -// -// The Driver is responsible for: -// - Plugin lifecycle management -// - Device discovery via RPC API -// - Device pairing with username/password authentication -// - Device instance creation and management -// - Driver-level capabilities reporting -// -// This implementation demonstrates best practices for: -// - Clean SDK interface implementation -// - Proper error handling and logging -// - Resource management and cleanup -// - Concurrent device management -// - Antminer-specific RPC protocol handling -package driver - -import ( - "context" - "fmt" - "log/slog" - "strings" - "sync" - - "github.com/block/proto-fleet/plugin/antminer/internal/device" - "github.com/block/proto-fleet/plugin/antminer/internal/types" - sdk "github.com/block/proto-fleet/server/sdk/v1" -) - -const ( - driverName = "antminer" - apiVersion = "v1" - requiredRPCPort = 4028 - manufacturer = "Bitmain" - versionTypePrefix = "Antminer" - - // Firmware variant markers — substrings that appear in the BMMiner/Miner - // version field for each aftermarket firmware. Checked case-insensitively. - firmwareMarkerLuxOS = "luxos" - firmwareMarkerBraiins = "braiins" - firmwareMarkerVNish = "vnish" - firmwareMarkerMaraFW = "marafw" - firmwareMarkerEpic = "epic" -) - -// nonStockFirmwareMarkers are case-insensitive substrings that indicate -// non-stock firmware that should be handled by a specialized plugin (e.g. asicrs). -var nonStockFirmwareMarkers = []string{ - firmwareMarkerLuxOS, - firmwareMarkerBraiins, - firmwareMarkerVNish, - firmwareMarkerMaraFW, - firmwareMarkerEpic, -} - -// isNonStockFirmware reports whether the firmware string indicates a non-stock variant. -func isNonStockFirmware(firmware string) bool { - lower := strings.ToLower(firmware) - for _, marker := range nonStockFirmwareMarkers { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -var _ sdk.Driver = (*Driver)(nil) -var _ sdk.DefaultCredentialsProvider = (*Driver)(nil) -var _ sdk.ModelCapabilitiesProvider = (*Driver)(nil) -var _ sdk.DiscoveryPortsProvider = (*Driver)(nil) - -// defaultCredentials contains well-known factory defaults for Bitmain Antminer devices. -// These are publicly documented and tried in order during auto-authentication. -var defaultCredentials = []sdk.UsernamePassword{ - {Username: "root", Password: "root"}, -} - -// efficiencyModeModels are model prefixes that support efficiency (low power) mode. -var efficiencyModeModels = []string{ - "Antminer S17", - "Antminer S19", - "Antminer T17", - "Antminer T19", -} - -// noEfficiencyModeModels are model prefixes that do NOT support efficiency mode. -// These take precedence over efficiencyModeModels. -var noEfficiencyModeModels = []string{ - "Antminer S21", - "Antminer T21", -} - -// Driver implements the SDK Driver interface for Antminer devices. -type Driver struct { - devices map[string]sdk.Device - mutex sync.RWMutex - - clientFactory types.ClientFactory -} - -// New creates a new Antminer driver instance. -// -// The clientFactory parameter is required and allows for dependency injection. -// This enables easy testing with mock clients. -func New(clientFactory types.ClientFactory) (*Driver, error) { - driver := &Driver{ - devices: make(map[string]sdk.Device), - clientFactory: clientFactory, - } - - return driver, nil -} - -// Handshake implements the SDK Driver interface. -// -// This method identifies the plugin to the Fleet server. -// It should return consistent values across plugin restarts. -func (d *Driver) Handshake(ctx context.Context) (sdk.DriverIdentifier, error) { - return sdk.DriverIdentifier{ - DriverName: driverName, - APIVersion: apiVersion, - }, nil -} - -// DescribeDriver implements the SDK Driver interface. -// -// This method reports the driver's capabilities to the Fleet server. -// Capabilities determine which SDK methods the server will call. -func (d *Driver) DescribeDriver(ctx context.Context) (sdk.DriverIdentifier, sdk.Capabilities, error) { - deviceInfo := sdk.DriverIdentifier{ - DriverName: driverName, - APIVersion: apiVersion, - } - - capabilities := sdk.Capabilities{ - // Core capabilities - sdk.CapabilityPollingHost: true, - sdk.CapabilityDiscovery: true, - sdk.CapabilityPairing: true, - - // Command capabilities - sdk.CapabilityReboot: true, - sdk.CapabilityMiningStart: true, - sdk.CapabilityMiningStop: true, - sdk.CapabilityCurtailFull: true, // FULL curtailment uses mining start/stop. - sdk.CapabilityLEDBlink: true, - sdk.CapabilityFactoryReset: false, - sdk.CapabilityCoolingModeAir: false, - sdk.CapabilityCoolingModeImmerse: false, - sdk.CapabilityPoolConfig: true, - sdk.CapabilityPoolPriority: true, - sdk.CapabilityLogsDownload: true, - - // Power mode capabilities are model-specific; see GetCapabilitiesForModel. - sdk.CapabilityPowerModeEfficiency: false, - - // Security capabilities - sdk.CapabilityUpdateMinerPassword: true, - - // Telemetry capabilities - sdk.CapabilityRealtimeTelemetry: true, - sdk.CapabilityHistoricalData: false, - sdk.CapabilityHashrateReported: true, - sdk.CapabilityPowerUsage: false, - sdk.CapabilityTemperature: true, - sdk.CapabilityFanSpeed: true, - sdk.CapabilityEfficiency: false, - sdk.CapabilityUptime: true, - sdk.CapabilityErrorCount: true, - sdk.CapabilityMinerStatus: true, - sdk.CapabilityPoolStats: true, - sdk.CapabilityPerChipStats: true, - sdk.CapabilityPerBoardStats: true, - sdk.CapabilityPSUStats: false, - - // Firmware capabilities - sdk.CapabilityFirmware: true, - sdk.CapabilityOTAUpdate: false, - sdk.CapabilityManualUpload: true, - - // Authentication capabilities - sdk.CapabilityBasicAuth: true, - - // Advanced capabilities - sdk.CapabilityPollingPlugin: false, - sdk.CapabilityBatchStatus: false, - sdk.CapabilityStreaming: false, - } - - return deviceInfo, capabilities, nil -} - -// GetDiscoveryPorts returns the canonical RPC discovery port for Antminers. -func (d *Driver) GetDiscoveryPorts(_ context.Context) []string { - return []string{fmt.Sprint(requiredRPCPort)} -} - -// DiscoverDevice implements the SDK Driver interface. -// -// This method attempts to discover an Antminer at the given network address. -// It demonstrates: -// - RPC connectivity testing -// - Device identification via version command -// - Antminer-specific validation -func (d *Driver) DiscoverDevice(ctx context.Context, ipAddress, port string) (sdk.DeviceInfo, error) { - slog.Debug("Discovering Antminer device", "ip", ipAddress, "port", port) - - if port != fmt.Sprint(requiredRPCPort) { - return sdk.DeviceInfo{}, sdk.NewErrorDeviceNotFound(ipAddress, - fmt.Errorf("antminers use port %d for RPC, got %s", requiredRPCPort, port)) - } - - rpcPort, err := sdk.ParsePort(port) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("invalid RPC port number: %w", err) - } - - webPort := types.WebPort() - - client, err := d.clientFactory(ipAddress, rpcPort, webPort, "http") - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to create client: %w", err) - } - defer client.Close() - - versionResp, err := client.GetVersion(ctx) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to get version info: %w", err) - } - - if len(versionResp.Version) == 0 { - return sdk.DeviceInfo{}, fmt.Errorf("empty version info from device") - } - - versionInfo := versionResp.Version[0] - - if !strings.HasPrefix(versionInfo.Type, versionTypePrefix) { - return sdk.DeviceInfo{}, fmt.Errorf("not an Antminer device: %s", versionInfo.Type) - } - - model := versionInfo.Type - if model == "" { - model = "Unknown Antminer" - } - - // Extract firmware version from version info - firmwareVersion := versionInfo.BMMiner - if firmwareVersion == "" { - firmwareVersion = versionInfo.Miner - } - - if versionInfo.LUXminer != "" { - return sdk.DeviceInfo{}, sdk.NewErrorDeviceNotFound(ipAddress, - fmt.Errorf("LuxOS firmware detected, skipping antminer plugin")) - } - - if isNonStockFirmware(firmwareVersion) { - return sdk.DeviceInfo{}, sdk.NewErrorDeviceNotFound(ipAddress, - fmt.Errorf("non-stock firmware detected (%s), skipping antminer plugin", firmwareVersion)) - } - - return sdk.DeviceInfo{ - Host: ipAddress, - Port: rpcPort, - URLScheme: "http", - SerialNumber: "", - Model: model, - Manufacturer: manufacturer, - MacAddress: "", - FirmwareVersion: firmwareVersion, - }, nil -} - -// PairDevice implements the SDK Driver interface. -// -// This method establishes a trusted relationship with a discovered device. -// For Antminers, this involves username/password authentication. -func (d *Driver) PairDevice(ctx context.Context, deviceInfo sdk.DeviceInfo, access sdk.SecretBundle) (sdk.DeviceInfo, error) { - slog.Debug("Pairing Antminer device", "host", deviceInfo.Host, "model", deviceInfo.Model) - - credentials, err := d.extractUsernamePassword(access) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to extract credentials: %w", err) - } - - webPort := types.WebPort() - client, err := d.clientFactory(deviceInfo.Host, requiredRPCPort, webPort, deviceInfo.URLScheme) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to create client: %w", err) - } - defer client.Close() - - err = client.SetCredentials(credentials) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to set credentials: %w", err) - } - - if err := client.Pair(ctx, credentials); err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("pairing failed: %w", err) - } - - deviceInfoResp, err := client.GetDeviceInfo(ctx) - if err != nil { - return sdk.DeviceInfo{}, fmt.Errorf("failed to get device info after pairing: %w", err) - } - - deviceInfo.SerialNumber = deviceInfoResp.SerialNumber - deviceInfo.MacAddress = deviceInfoResp.MacAddress - - // Get firmware version during pairing - versionResp, err := client.GetVersion(ctx) - if err != nil { - slog.Debug("failed to get version during pairing", "error", err) - } else if len(versionResp.Version) > 0 { - deviceInfo.FirmwareVersion = versionResp.Version[0].BMMiner - if deviceInfo.FirmwareVersion == "" { - deviceInfo.FirmwareVersion = versionResp.Version[0].Miner - } - } - - slog.Debug("Device paired successfully, returning device info", - "host", deviceInfo.Host, - "model", deviceInfo.Model, - "serial", deviceInfo.SerialNumber, - "mac", deviceInfo.MacAddress, - "firmware_version", deviceInfo.FirmwareVersion, - "username", credentials.Username) - - return deviceInfo, nil -} - -// NewDevice implements the SDK Driver interface. -// -// This method creates a new device instance for management. -// It demonstrates: -// - Device instance lifecycle management -// - Credential handling and storage -// - Concurrent device tracking -func (d *Driver) NewDevice(ctx context.Context, deviceID string, deviceInfo sdk.DeviceInfo, secret sdk.SecretBundle) (sdk.NewDeviceResult, error) { - slog.Debug("Creating new Antminer device instance", "deviceID", deviceID, "host", deviceInfo.Host) - - credentials, err := d.extractUsernamePassword(secret) - if err != nil { - return sdk.NewDeviceResult{}, fmt.Errorf("failed to extract credentials: %w", err) - } - - dev, err := device.New(deviceID, deviceInfo, credentials, d.clientFactory) - if err != nil { - return sdk.NewDeviceResult{}, fmt.Errorf("failed to create device: %w", err) - } - - err = dev.Connect(ctx) - if err != nil { - return sdk.NewDeviceResult{}, fmt.Errorf("failed to connect device: %w", err) - } - - d.mutex.Lock() - d.devices[deviceID] = dev - d.mutex.Unlock() - - slog.Info("Antminer device instance created", "deviceID", deviceID, "host", deviceInfo.Host, "username", credentials.Username) - return sdk.NewDeviceResult{Device: dev}, nil -} - -func (d *Driver) extractUsernamePassword(secret sdk.SecretBundle) (sdk.UsernamePassword, error) { - switch kind := secret.Kind.(type) { - case sdk.UsernamePassword: - return kind, nil - default: - return sdk.UsernamePassword{}, fmt.Errorf("unsupported secret bundle type for Antminer: %T (expected UsernamePassword)", secret.Kind) - } -} - -// GetDefaultCredentials implements sdk.DefaultCredentialsProvider. -// Returns known default credentials for Antminer devices to enable auto-authentication during pairing. -func (d *Driver) GetDefaultCredentials(_ context.Context, _, _ string) []sdk.UsernamePassword { - return defaultCredentials -} - -// GetCapabilitiesForModel implements sdk.ModelCapabilitiesProvider. -// Stock Bitmain firmware is SV1-only and the plugin rejects non-stock -// firmware at discovery, so CapabilityNativeStratumV2 is never set. -func (d *Driver) GetCapabilitiesForModel(_ context.Context, _, model string) sdk.Capabilities { - caps := sdk.Capabilities{} - if d.modelSupportsEfficiencyMode(model) { - caps[sdk.CapabilityPowerModeEfficiency] = true - } - return caps -} - -// modelSupportsEfficiencyMode checks if the given model supports efficiency/low power mode. -func (d *Driver) modelSupportsEfficiencyMode(model string) bool { - // Check exclusion list first (takes priority) - for _, prefix := range noEfficiencyModeModels { - if strings.HasPrefix(model, prefix) { - return false - } - } - - // Check inclusion list - for _, prefix := range efficiencyModeModels { - if strings.HasPrefix(model, prefix) { - return true - } - } - - // Unknown models: default to not supported (safe default) - return false -} diff --git a/plugin/antminer/internal/driver/driver_test.go b/plugin/antminer/internal/driver/driver_test.go deleted file mode 100644 index 3679d2cae4..0000000000 --- a/plugin/antminer/internal/driver/driver_test.go +++ /dev/null @@ -1,613 +0,0 @@ -package driver - -import ( - "testing" - "time" - - "github.com/block/proto-fleet/plugin/antminer/internal/types" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/mocks" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - sdk "github.com/block/proto-fleet/server/sdk/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -const ( - testIPAddress = "192.168.1.100" - correctPort = "4028" - driverTestVersion = "v1" - driverTestName = "antminer" -) - -// createMockClientFactory creates a simple mock client factory for tests that don't need actual client functionality -func createMockClientFactory() types.ClientFactory { - return types.ClientFactory(func(_ string, _, _ int32, _ string) (antminer.AntminerClient, error) { - return nil, nil // Not used in basic tests - }) -} - -// createRealClientFactory creates a real client factory that will attempt actual connections -func createRealClientFactory() types.ClientFactory { - return types.ClientFactory(func(host string, rpcPort, webPort int32, urlScheme string) (antminer.AntminerClient, error) { - return antminer.NewClient(host, rpcPort, webPort, urlScheme) - }) -} - -func TestNew(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - require.NotNil(t, d) - assert.NotNil(t, d.devices) - assert.NotNil(t, d.clientFactory) -} - -func TestHandshake(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - identifier, err := d.Handshake(ctx) - require.NoError(t, err) - - assert.Equal(t, driverTestName, identifier.DriverName) - assert.Equal(t, driverTestVersion, identifier.APIVersion) -} - -func TestDescribeDriver(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - identifier, capabilities, err := d.DescribeDriver(ctx) - require.NoError(t, err) - - // Verify identifier - assert.Equal(t, driverTestName, identifier.DriverName) - assert.Equal(t, driverTestVersion, identifier.APIVersion) - - // Check that required capabilities are present - assert.True(t, capabilities[sdk.CapabilityPollingHost]) - assert.True(t, capabilities[sdk.CapabilityDiscovery]) - assert.True(t, capabilities[sdk.CapabilityPairing]) - - // Check command capabilities - assert.True(t, capabilities[sdk.CapabilityReboot]) - assert.True(t, capabilities[sdk.CapabilityMiningStart]) - assert.True(t, capabilities[sdk.CapabilityMiningStop]) - assert.True(t, capabilities[sdk.CapabilityCurtailFull]) - assert.False(t, capabilities[sdk.CapabilityCurtailEfficiency]) - assert.True(t, capabilities[sdk.CapabilityLEDBlink]) - assert.False(t, capabilities[sdk.CapabilityFactoryReset]) - assert.False(t, capabilities[sdk.CapabilityCoolingModeAir]) - assert.False(t, capabilities[sdk.CapabilityCoolingModeImmerse]) - assert.True(t, capabilities[sdk.CapabilityPoolConfig]) - assert.True(t, capabilities[sdk.CapabilityPoolPriority]) - assert.True(t, capabilities[sdk.CapabilityLogsDownload]) - - // Power mode is model-specific, base is false - assert.False(t, capabilities[sdk.CapabilityPowerModeEfficiency]) - - // Check telemetry capabilities - assert.True(t, capabilities[sdk.CapabilityRealtimeTelemetry]) - assert.False(t, capabilities[sdk.CapabilityHistoricalData]) - - // Check firmware capabilities - assert.True(t, capabilities[sdk.CapabilityFirmware]) - assert.False(t, capabilities[sdk.CapabilityOTAUpdate]) - assert.True(t, capabilities[sdk.CapabilityManualUpload]) - - // Check authentication capabilities - assert.True(t, capabilities[sdk.CapabilityBasicAuth]) - - // Check that unsupported capabilities are false - assert.False(t, capabilities[sdk.CapabilityPollingPlugin]) - assert.False(t, capabilities[sdk.CapabilityBatchStatus]) - assert.False(t, capabilities[sdk.CapabilityStreaming]) -} - -func TestGetCapabilitiesForModel(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - - tests := []struct { - name string - model string - expectedEfficiencySupport bool - }{ - // S17 series - supports efficiency mode - { - name: "S17 base model", - model: "Antminer S17", - expectedEfficiencySupport: true, - }, - { - name: "S17 Pro", - model: "Antminer S17 Pro", - expectedEfficiencySupport: true, - }, - { - name: "S17+", - model: "Antminer S17+", - expectedEfficiencySupport: true, - }, - // S19 series - supports efficiency mode - { - name: "S19 base model", - model: "Antminer S19", - expectedEfficiencySupport: true, - }, - { - name: "S19 Pro", - model: "Antminer S19 Pro", - expectedEfficiencySupport: true, - }, - { - name: "S19j Pro", - model: "Antminer S19j Pro", - expectedEfficiencySupport: true, - }, - { - name: "S19 XP", - model: "Antminer S19 XP", - expectedEfficiencySupport: true, - }, - // T17 series - supports efficiency mode - { - name: "T17 base model", - model: "Antminer T17", - expectedEfficiencySupport: true, - }, - { - name: "T17+", - model: "Antminer T17+", - expectedEfficiencySupport: true, - }, - // T19 series - supports efficiency mode - { - name: "T19 base model", - model: "Antminer T19", - expectedEfficiencySupport: true, - }, - // S21 series - does NOT support efficiency mode - { - name: "S21 base model", - model: "Antminer S21", - expectedEfficiencySupport: false, - }, - { - name: "S21 Pro", - model: "Antminer S21 Pro", - expectedEfficiencySupport: false, - }, - { - name: "S21 Hyd", - model: "Antminer S21 Hyd", - expectedEfficiencySupport: false, - }, - // T21 series - does NOT support efficiency mode - { - name: "T21 base model", - model: "Antminer T21", - expectedEfficiencySupport: false, - }, - // Unknown models - default to not supported (safe default) - { - name: "Unknown model", - model: "Antminer X99", - expectedEfficiencySupport: false, - }, - { - name: "Non-Antminer model", - model: "Whatsminer M50", - expectedEfficiencySupport: false, - }, - { - name: "Empty model", - model: "", - expectedEfficiencySupport: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - caps := d.GetCapabilitiesForModel(ctx, "Bitmain", tt.model) - require.NotNil(t, caps) - assert.Equal(t, tt.expectedEfficiencySupport, caps[sdk.CapabilityPowerModeEfficiency], - "model %q should have efficiency support = %v", tt.model, tt.expectedEfficiencySupport) - }) - } -} - -func TestModelSupportsEfficiencyMode(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - tests := []struct { - model string - expected bool - }{ - // Supported models - {"Antminer S17", true}, - {"Antminer S17 Pro", true}, - {"Antminer S19", true}, - {"Antminer S19j Pro", true}, - {"Antminer T17", true}, - {"Antminer T19", true}, - - // Unsupported models (S21/T21 series) - {"Antminer S21", false}, - {"Antminer S21 Pro", false}, - {"Antminer T21", false}, - - // Unknown models default to false - {"Antminer S15", false}, - {"Unknown", false}, - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.model, func(t *testing.T) { - result := d.modelSupportsEfficiencyMode(tt.model) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestDiscoverDevice_Success(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - // Create driver with mock client factory - d, err := New(func(host string, rpcPort, webPort int32, urlScheme string) (antminer.AntminerClient, error) { - // Verify the parameters passed to the factory - assert.Equal(t, testIPAddress, host) - assert.Equal(t, int32(4028), rpcPort) - assert.Equal(t, int32(80), webPort) - assert.Equal(t, "http", urlScheme) - return mockClient, nil - }) - require.NoError(t, err) - - // Set up mock expectations - mockClient.EXPECT(). - GetVersion(gomock.Any()). - Return(&rpc.VersionResponse{ - Status: []rpc.StatusInfo{{ - Status: "S", - When: time.Now().Unix(), - Code: 1, - Msg: "Success", - }}, - Version: []rpc.VersionInfo{{ - BMMiner: "2.0.0", - API: "3.1", - Miner: "S19j Pro", - CompileTime: "2023-01-01 00:00:00", - Type: "Antminer S19j Pro", - }}, - ID: 1, - }, nil) - - mockClient.EXPECT(). - Close(). - Times(1) - - // Test discovery - ctx := t.Context() - result, err := d.DiscoverDevice(ctx, testIPAddress, correctPort) - require.NoError(t, err) - - // Verify results - assert.Equal(t, testIPAddress, result.Host) - assert.Equal(t, int32(4028), result.Port) - assert.Equal(t, "http", result.URLScheme) - assert.Equal(t, "Antminer S19j Pro", result.Model) - assert.Equal(t, "Bitmain", result.Manufacturer) -} - -func TestDiscoverDevice_WrongPort(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - _, err = d.DiscoverDevice(ctx, testIPAddress, "80") - require.Error(t, err) - var sdkErr sdk.SDKError - assert.ErrorAs(t, err, &sdkErr) - assert.Equal(t, sdk.ErrCodeDeviceNotFound, sdkErr.Code) -} - -func TestDiscoverDevice_InvalidPort(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - _, err = d.DiscoverDevice(ctx, testIPAddress, "invalid") - require.Error(t, err) - // The driver validates port is 4028, so it will fail with device not found error - var sdkErr sdk.SDKError - assert.ErrorAs(t, err, &sdkErr) - assert.Equal(t, sdk.ErrCodeDeviceNotFound, sdkErr.Code) -} - -func TestDiscoverDevice_NotAntminer(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - d, err := New(func(_ string, _, _ int32, _ string) (antminer.AntminerClient, error) { - return mockClient, nil - }) - require.NoError(t, err) - - // Set up mock expectations for non-Antminer device - mockClient.EXPECT(). - GetVersion(gomock.Any()). - Return(&rpc.VersionResponse{ - Status: []rpc.StatusInfo{{ - Status: "S", - When: time.Now().Unix(), - Code: 1, - Msg: "Success", - }}, - Version: []rpc.VersionInfo{{ - BMMiner: "1.0.0", - API: "3.0", - Miner: "Unknown", - Type: "OtherMiner", // Not an Antminer - }}, - ID: 1, - }, nil) - - mockClient.EXPECT(). - Close(). - Times(1) - - // Test discovery - _, err = d.DiscoverDevice(t.Context(), testIPAddress, correctPort) - require.Error(t, err) - assert.Contains(t, err.Error(), "not an Antminer device") -} - -func TestDiscoverDevice_RejectsNonStockFirmware(t *testing.T) { - tests := []struct { - name string - bmminer string - miner string - luxminer string - }{ - {name: "luxos in BMMiner", bmminer: "LuxOS 2.1.0"}, - {name: "braiins in BMMiner", bmminer: "Braiins OS+ 22.08"}, - {name: "vnish in BMMiner", bmminer: "VNish 1.2.7"}, - {name: "luxos in Miner fallback", miner: "luxos-1.0"}, - {name: "marathon in BMMiner", bmminer: "MARAFW_1.0.0"}, - // LuxOS reports firmware in a custom "LUXminer" field; BMMiner is empty - // and Miner contains only a bare version number with no recognizable marker. - {name: "luxos via LUXminer field", luxminer: "2025.4.8.220305-57b389c7", miner: "2025.4.8.220305"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Arrange - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - d, err := New(func(_ string, _, _ int32, _ string) (antminer.AntminerClient, error) { - return mockClient, nil - }) - require.NoError(t, err) - - mockClient.EXPECT(). - GetVersion(gomock.Any()). - Return(&rpc.VersionResponse{ - Status: []rpc.StatusInfo{{ - Status: "S", - When: time.Now().Unix(), - Code: 1, - Msg: "Success", - }}, - Version: []rpc.VersionInfo{{ - BMMiner: tt.bmminer, - LUXminer: tt.luxminer, - API: "3.1", - Miner: tt.miner, - Type: "Antminer S19", - }}, - ID: 1, - }, nil) - - mockClient.EXPECT(). - Close(). - Times(1) - - // Act - _, err = d.DiscoverDevice(t.Context(), testIPAddress, correctPort) - - // Assert - require.Error(t, err) - var sdkErr sdk.SDKError - assert.ErrorAs(t, err, &sdkErr) - assert.Equal(t, sdk.ErrCodeDeviceNotFound, sdkErr.Code) - }) - } -} - -func TestDiscoverDevice_UnknownModel(t *testing.T) { - d, err := New(createRealClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - // This will fail at port validation, which is expected - _, err = d.DiscoverDevice(ctx, testIPAddress, correctPort) - require.Error(t, err) - // Will fail due to no real connection - assert.Contains(t, err.Error(), "failed to") -} - -func TestDiscoverDevice_ConnectionFailure(t *testing.T) { - d, err := New(createRealClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - // Try to connect to non-existent host - _, err = d.DiscoverDevice(ctx, "192.168.255.255", correctPort) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to") -} - -func TestPairDevice_Success(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClient := mocks.NewMockAntminerClient(ctrl) - - d, err := New(func(_ string, _, _ int32, _ string) (antminer.AntminerClient, error) { - return mockClient, nil - }) - require.NoError(t, err) - - // Set up mock expectations - mockClient.EXPECT(). - Pair(gomock.Any(), sdk.UsernamePassword{Username: "admin", Password: "password"}). - Return(nil) - - mockClient.EXPECT(). - GetDeviceInfo(gomock.Any()). - Return(&antminer.DeviceInfo{ - SerialNumber: "ABC123456789", - Model: "S19j Pro", - Manufacturer: "Bitmain", - MacAddress: "00:11:22:33:44:55", - }, nil) - - mockClient.EXPECT(). - GetVersion(gomock.Any()). - Return(&rpc.VersionResponse{ - Version: []rpc.VersionInfo{ - { - BMMiner: "2.0.0", - Miner: "1.0.0", - }, - }, - }, nil) - - mockClient.EXPECT(). - SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}). - Return(nil) - - mockClient.EXPECT(). - Close(). - Times(1) - - ctx := t.Context() - deviceInfo := sdk.DeviceInfo{ - Host: testIPAddress, - Port: 80, - URLScheme: "http", - Model: "S19j Pro", - Manufacturer: "Bitmain", - } - - validSecret := sdk.SecretBundle{ - Kind: sdk.UsernamePassword{ - Username: "admin", - Password: "password", - }, - } - - result, err := d.PairDevice(ctx, deviceInfo, validSecret) - require.NoError(t, err) - assert.Equal(t, "S19j Pro", result.Model) - assert.Equal(t, "ABC123456789", result.SerialNumber) - assert.Equal(t, "00:11:22:33:44:55", result.MacAddress) - assert.Equal(t, "2.0.0", result.FirmwareVersion) - assert.Equal(t, deviceInfo.Host, result.Host) - assert.Equal(t, deviceInfo.Port, result.Port) -} - -func TestPairDevice_InvalidCredentials(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - deviceInfo := sdk.DeviceInfo{ - Host: testIPAddress, - Port: 80, - URLScheme: "http", - Model: "S19j Pro", - Manufacturer: "Bitmain", - } - - invalidSecret := sdk.SecretBundle{ - Kind: sdk.TLSClientCert{ - ClientCertPEM: []byte("cert"), - KeyPEM: []byte("key"), - }, - } - - _, err = d.PairDevice(ctx, deviceInfo, invalidSecret) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to extract credentials") -} - -func TestNewDevice_InvalidCredentials(t *testing.T) { - d, err := New(createMockClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - deviceInfo := sdk.DeviceInfo{ - Host: testIPAddress, - Port: 80, - URLScheme: "http", - Model: "S19j Pro", - Manufacturer: "Bitmain", - } - - invalidSecret := sdk.SecretBundle{ - Kind: sdk.TLSClientCert{ - ClientCertPEM: []byte("cert"), - KeyPEM: []byte("key"), - }, - } - - _, err = d.NewDevice(ctx, "test-device", deviceInfo, invalidSecret) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to extract credentials") -} - -func TestNewDevice_ValidCredentials(t *testing.T) { - d, err := New(createRealClientFactory()) - require.NoError(t, err) - - ctx := t.Context() - deviceInfo := sdk.DeviceInfo{ - Host: testIPAddress, - Port: 80, - URLScheme: "http", - Model: "S19j Pro", - Manufacturer: "Bitmain", - } - - validSecret := sdk.SecretBundle{ - Kind: sdk.UsernamePassword{ - Username: "admin", - Password: "password", - }, - } - - // This will fail due to network connection, but we can test credential extraction - _, err = d.NewDevice(ctx, "test-device", deviceInfo, validSecret) - require.Error(t, err) - // Should fail at device creation, not credential extraction - assert.NotContains(t, err.Error(), "failed to extract credentials") - assert.Contains(t, err.Error(), "failed to connect device") -} diff --git a/plugin/antminer/internal/types/types.go b/plugin/antminer/internal/types/types.go deleted file mode 100644 index 6435fd69fb..0000000000 --- a/plugin/antminer/internal/types/types.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package types contains shared types used across the Antminer plugin -package types - -import ( - "os" - "strconv" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" -) - -// ClientFactory is a function type for creating Antminer clients -// This allows for dependency injection and easier testing -type ClientFactory func(host string, rpcPort, webPort int32, urlScheme string) (antminer.AntminerClient, error) - -// WebPort returns the web API port, reading from ANTMINER_WEB_PORT env var -// with a default of 80. -func WebPort() int32 { - if v := os.Getenv("ANTMINER_WEB_PORT"); v != "" { - if port, err := strconv.ParseInt(v, 10, 32); err == nil { - return int32(port) - } - } - return 80 -} - -// StatusCacheTTL returns the status cache TTL, reading from ANTMINER_STATUS_CACHE_TTL -// env var (Go duration string) with a default of 5s. -func StatusCacheTTL() time.Duration { - if v := os.Getenv("ANTMINER_STATUS_CACHE_TTL"); v != "" { - if d, err := time.ParseDuration(v); err == nil { - return d - } - } - return 5 * time.Second -} diff --git a/plugin/antminer/justfile b/plugin/antminer/justfile deleted file mode 100644 index fb94488db9..0000000000 --- a/plugin/antminer/justfile +++ /dev/null @@ -1,20 +0,0 @@ -import '../../just/go-plugin.just' - -# list available recipes -default: - just --list - -# run all tests -test: - go test ./... - -# run unit tests only -test-unit: - go test ./tests/unit/... - -# run integration tests only -test-integration: - go test ./tests/... - -# run linting and tests together -check: lint test diff --git a/plugin/antminer/main.go b/plugin/antminer/main.go deleted file mode 100644 index c0955aa2c2..0000000000 --- a/plugin/antminer/main.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "log" - - "github.com/block/proto-fleet/plugin/antminer/internal/driver" - "github.com/block/proto-fleet/plugin/antminer/internal/types" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" - "github.com/block/proto-fleet/server/sdk/v1" - "github.com/hashicorp/go-plugin" -) - -func main() { - clientFactory := types.ClientFactory(func(host string, rpcPort, webPort int32, urlScheme string) (antminer.AntminerClient, error) { - return antminer.NewClient(host, rpcPort, webPort, urlScheme) - }) - - antminerDriver, err := driver.New(clientFactory) - if err != nil { - log.Fatalf("Failed to create antminer driver: %v", err) - } - - plugin.Serve(&plugin.ServeConfig{ - HandshakeConfig: sdk.HandshakeConfig, - Plugins: map[string]plugin.Plugin{ - "driver": &sdk.DriverPlugin{Impl: antminerDriver}, - }, - GRPCServer: plugin.DefaultGRPCServer, - }) -} diff --git a/plugin/antminer/pkg/antminer/client.go b/plugin/antminer/pkg/antminer/client.go deleted file mode 100644 index 7f0fc4847d..0000000000 --- a/plugin/antminer/pkg/antminer/client.go +++ /dev/null @@ -1,832 +0,0 @@ -// Package antminer provides client functionality for communicating with Bitmain Antminer devices. -// -// This package implements both RPC and Web API clients for comprehensive device management: -// - RPC API for mining status and control -// - Web API for configuration and authentication -// - Unified interface for device operations -// - Proper error handling and timeouts -package antminer - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - "github.com/block/proto-fleet/server/sdk/v1" -) - -const ( - // GHSToHS converts GH/s to H/s - GHSToHS = 1e9 - // THSToGHS converts TH/s to GH/s - THSToGHS = 1000 - defaultTimeOut = 30 * time.Second - - // Set to absolute zero in Celsius as any reading below this is invalid - minValidTemperature = -273.15 - - // Temperature sensor positions in TempChip array [inlet_1, inlet_2, outlet_1, outlet_2] - // Based on Antminer hardware sensor layout - tempSensorCount = 4 - inletTempStartIndex = 0 - inletTempEndIndex = 2 - outletTempStartIndex = 2 - outletTempEndIndex = 4 -) - -// Client constants -const ( - DefaultDialTimeout = 10 * time.Second - DefaultReadTimeout = 30 * time.Second - MaxResponseSize = 1 << 20 // 1MB - DefaultRPCPort = "4028" - DefaultWebPort = "80" -) - -// Client provides a unified interface for communicating with Antminer devices -type Client struct { - connectInfo *web.AntminerConnectionInfo - host string - rpcPort int32 - webPort int32 - urlScheme string - credentials *sdk.UsernamePassword - - // HTTP client for web API - httpClient *http.Client - - // Web API client - webClient web.WebAPIClient - - // RPC client - rpcClient rpc.RPCClient - - stopBlinkMux sync.Mutex -} - -// Credentials holds authentication information -type Credentials struct { - Username string - Password string -} - -// DeviceInfo represents basic device information -type DeviceInfo struct { - SerialNumber string - Model string - Manufacturer string - MacAddress string -} - -// Status represents the current mining status -type Status struct { - State sdk.HealthStatus - ErrorMessage string - FirmwareVersion string -} - -// Telemetry represents device telemetry data -type Telemetry struct { - // Device-level aggregates - HashrateHS *float64 - TemperatureCelsius *float64 - FanRPM *float64 - UptimeSeconds *int64 - - // Power and efficiency (from RPC stats command, not all firmware versions report these) - PowerWatts *float64 - EfficiencyJPerHash *float64 - - // Component-level metrics - HashBoards []HashBoardTelemetry - Fans []FanTelemetry - HardwareErrorRate *float64 -} - -// HashBoardTelemetry represents per-hashboard (chain) telemetry -type HashBoardTelemetry struct { - Index int - SerialNumber string - HashrateHS *float64 - Temperature *float64 - InletTemp *float64 - OutletTemp *float64 - ChipCount int - ChipFrequencyMHz int - HardwareErrors int -} - -// FanTelemetry represents per-fan telemetry -type FanTelemetry struct { - Index int - RPM int -} - -// Pool represents a mining pool configuration -type Pool struct { - Priority int - URL string - WorkerName string -} - -// NewClient creates a new Antminer client -func NewClient(host string, rpcPort, webPort int32, urlScheme string) (*Client, error) { - dialTimeout := DefaultDialTimeout - readTimeout := DefaultReadTimeout - - // Create protocol from scheme - protocol, err := networking.ProtocolFromString(urlScheme) - if err != nil { - return nil, fmt.Errorf("invalid URL scheme: %w", err) - } - - // Create connection info for web API - connInfo, err := networking.NewConnectionInfo(host, fmt.Sprintf("%d", webPort), protocol) - if err != nil { - return nil, fmt.Errorf("failed to create connection info: %w", err) - } - - client := &Client{ - connectInfo: &web.AntminerConnectionInfo{ - ConnectionInfo: *connInfo, - Creds: sdk.UsernamePassword{}, // Empty credentials initially - }, - host: host, - rpcPort: rpcPort, - webPort: webPort, - urlScheme: urlScheme, - httpClient: &http.Client{ - Timeout: defaultTimeOut, - }, - webClient: web.NewService(), - rpcClient: rpc.NewService(rpc.WithDialTimeout(dialTimeout), rpc.WithReadTimeout(readTimeout)), - } - - return client, nil -} - -// getWebConnectionInfo creates connection info for web API calls -func (c *Client) getWebConnectionInfo() *web.AntminerConnectionInfo { - var creds sdk.UsernamePassword - if c.credentials != nil { - creds = *c.credentials - } - - return web.NewAntminerConnectionInfo( - c.connectInfo.ConnectionInfo, - creds, - ) -} - -// getRPCConnectionInfo creates connection info for RPC calls (uses TCP protocol) -func (c *Client) getRPCConnectionInfo() (*networking.ConnectionInfo, error) { - connInfo, err := networking.NewConnectionInfo(c.host, fmt.Sprintf("%d", c.rpcPort), networking.ProtocolTCP) - if err != nil { - return nil, fmt.Errorf("failed to create RPC connection info: %w", err) - } - return connInfo, nil -} - -// Close closes the client and cleans up resources -func (c *Client) Close() { - if c.httpClient != nil { - c.httpClient.CloseIdleConnections() - } -} - -// GetDeviceInfo retrieves basic device information -func (c *Client) GetDeviceInfo(ctx context.Context) (*DeviceInfo, error) { - // Get version info via RPC - versionResp, err := c.GetVersion(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get version info: %w", err) - } - - if len(versionResp.Version) == 0 { - return nil, fmt.Errorf("no version information available") - } - - version := versionResp.Version[0] - - // Try to get system info via web API if credentials are available - var serialNumber, macAddress string - if c.credentials != nil { - connInfo := c.getWebConnectionInfo() - if systemInfo, err := c.webClient.GetSystemInfo(ctx, connInfo); err == nil { - serialNumber = systemInfo.SerialNumber - macAddress = systemInfo.MacAddr - } - } - - return &DeviceInfo{ - SerialNumber: serialNumber, - Model: version.Type, - Manufacturer: "Bitmain", - MacAddress: macAddress, - }, nil -} - -// GetStatus retrieves the current mining status -func (c *Client) GetStatus(ctx context.Context) (*Status, error) { - summaryResp, err := c.GetSummary(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get summary: %w", err) - } - - if len(summaryResp.Summary) == 0 { - return nil, fmt.Errorf("no summary information available") - } - - summary := summaryResp.Summary[0] - - // Determine state based on hashrate (not cumulative HardwareErrors counter). - // When hashrate is zero and credentials are available, also check the work mode - // to distinguish intentional sleep from an unexpected stop (matching ASIC-RS - // parse_is_mining behaviour: check work-mode first, fall back to hashrate). - var state sdk.HealthStatus - if summary.GHS5s > 0 { - state = sdk.HealthHealthyActive - } else if c.credentials != nil { - connInfo := c.getWebConnectionInfo() - config, err := c.webClient.GetMinerConfig(ctx, connInfo) - if err == nil { - workMode := config.BitmainWorkMode - if config.MinerMode != "" { - workMode = web.BitmainWorkMode(config.MinerMode) - } - if workMode == web.BitmainWorkModeSleep { - state = sdk.HealthHealthyInactive - } else { - // Zero hashrate while not in sleep mode — device should be mining but isn't. - state = sdk.HealthWarning - } - } else { - state = sdk.HealthHealthyInactive - } - } else { - state = sdk.HealthHealthyInactive - } - - // Get firmware version - versionResp, err := c.GetVersion(ctx) - firmwareVersion := "" - if err == nil && len(versionResp.Version) > 0 { - firmwareVersion = versionResp.Version[0].BMMiner - } - - return &Status{ - State: state, - ErrorMessage: "", - FirmwareVersion: firmwareVersion, - }, nil -} - -// extractTelemetryFromStats extracts comprehensive telemetry data from stats.cgi response -func extractTelemetryFromStats(stats *web.StatsInfo) (*Telemetry, error) { - if len(stats.STATS) == 0 { - return nil, fmt.Errorf("stats response contains no data") - } - - statsData := stats.STATS[0] - - // Validate critical fields - if statsData.ChainNum == 0 { - return nil, fmt.Errorf("invalid chain count: %d", statsData.ChainNum) - } - - if len(statsData.Chain) == 0 { - return nil, fmt.Errorf("stats response contains no chain data") - } - - // Calculate device-level aggregates - maxTemp, err := calculateMaxTemperature(statsData.Chain) - if err != nil { - return nil, fmt.Errorf("failed to calculate max temperature: %w", err) - } - - maxFanRPM := calculateMaxFanSpeed(statsData.Fan) - hwErrorRate := statsData.HWPTotal - - // Extract per-chain (hashboard) telemetry - hashBoards := make([]HashBoardTelemetry, 0, len(statsData.Chain)) - for _, chain := range statsData.Chain { - // Validate hashrate - if chain.RateReal < 0 { - return nil, fmt.Errorf("invalid hashrate for chain %d: %f GH/s", chain.Index, chain.RateReal) - } - - // Validate temperature array length before slicing - if len(chain.TempChip) < tempSensorCount { - return nil, fmt.Errorf("chain %d has insufficient temperature sensors: got %d, expected %d", - chain.Index, len(chain.TempChip), tempSensorCount) - } - - hashrate := chain.RateReal * GHSToHS - temp, err := calculateMaxTemperatureFromArray(chain.TempChip) - if err != nil { - return nil, fmt.Errorf("failed to calculate temperature for chain %d: %w", chain.Index, err) - } - - inletTemp, err := calculateAverageTemperatureFromArray(chain.TempChip[inletTempStartIndex:inletTempEndIndex]) - if err != nil { - return nil, fmt.Errorf("failed to calculate inlet temperature for chain %d: %w", chain.Index, err) - } - - outletTemp, err := calculateAverageTemperatureFromArray(chain.TempChip[outletTempStartIndex:outletTempEndIndex]) - if err != nil { - return nil, fmt.Errorf("failed to calculate outlet temperature for chain %d: %w", chain.Index, err) - } - - hashBoards = append(hashBoards, HashBoardTelemetry{ - Index: chain.Index, - SerialNumber: chain.SN, - HashrateHS: &hashrate, - Temperature: &temp, - InletTemp: &inletTemp, - OutletTemp: &outletTemp, - ChipCount: chain.ASICNum, - ChipFrequencyMHz: chain.FreqAvg, - HardwareErrors: chain.HW, - }) - } - - // Extract per-fan telemetry - fans := make([]FanTelemetry, len(statsData.Fan)) - for i, rpm := range statsData.Fan { - fans[i] = FanTelemetry{ - Index: i, - RPM: rpm, - } - } - - uptime := int64(statsData.Elapsed) - - return &Telemetry{ - TemperatureCelsius: &maxTemp, - FanRPM: &maxFanRPM, - UptimeSeconds: &uptime, - HashBoards: hashBoards, - Fans: fans, - HardwareErrorRate: &hwErrorRate, - // HashrateHS will be set from summary.cgi by GetTelemetry() - }, nil -} - -// calculateMaxTemperature calculates the maximum temperature across all chains -// Returns an error if no valid temperature readings are found -func calculateMaxTemperature(chains []web.ChainStats) (float64, error) { - if len(chains) == 0 { - return 0, fmt.Errorf("no chains provided") - } - - maxTemp := minValidTemperature - validTempFound := false - - for _, chain := range chains { - chainMax, err := calculateMaxTemperatureFromArray(chain.TempChip) - if err != nil { - // Skip chains with no valid temperatures rather than failing completely - // but log the issue for debugging - slog.Warn("Skipping chain due to invalid temperatures", "chain_index", chain.Index, "error", err) - continue - } - validTempFound = true - if chainMax > maxTemp { - maxTemp = chainMax - } - } - - if !validTempFound { - return 0, fmt.Errorf("no valid temperature readings found across %d chains", len(chains)) - } - - return maxTemp, nil -} - -// calculateMaxTemperatureFromArray returns the maximum temperature from an array -// Returns an error if no valid temperatures are found in the array -func calculateMaxTemperatureFromArray(temps []float64) (float64, error) { - if len(temps) == 0 { - return 0, fmt.Errorf("empty temperature array") - } - - maxTemp := minValidTemperature - validTempFound := false - - for _, temp := range temps { - if temp > minValidTemperature { - validTempFound = true - if temp > maxTemp { - maxTemp = temp - } - } - } - - if !validTempFound { - return 0, fmt.Errorf("no valid temperature readings in array (all temps <= %.2f°C)", minValidTemperature) - } - - return maxTemp, nil -} - -// calculateAverageTemperatureFromArray calculates average temperature from an array -// Returns an error if no valid temperatures are found in the array -func calculateAverageTemperatureFromArray(temps []float64) (float64, error) { - if len(temps) == 0 { - return 0, fmt.Errorf("empty temperature array") - } - - totalTemp := 0.0 - tempCount := 0 - - for _, temp := range temps { - if temp > minValidTemperature { - totalTemp += temp - tempCount++ - } - } - - if tempCount == 0 { - return 0, fmt.Errorf("no valid temperature readings in array (all temps <= %.2f°C)", minValidTemperature) - } - - return totalTemp / float64(tempCount), nil -} - -// calculateMaxFanSpeed returns the maximum fan speed from the fan array -func calculateMaxFanSpeed(fans []int) float64 { - maxRPM := 0 - for _, rpm := range fans { - if rpm > maxRPM { - maxRPM = rpm - } - } - return float64(maxRPM) -} - -// GetTelemetry retrieves device telemetry data -func (c *Client) GetTelemetry(ctx context.Context) (*Telemetry, error) { - summaryResp, err := c.GetSummary(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get summary: %w", err) - } - - if len(summaryResp.Summary) == 0 { - return nil, fmt.Errorf("no summary information available") - } - - summary := summaryResp.Summary[0] - - // Get temperature, fan, and component-level metrics from stats.cgi - if c.credentials == nil { - return nil, fmt.Errorf("credentials required for telemetry collection") - } - - connInfo := c.getWebConnectionInfo() - statsInfo, err := c.webClient.GetStatsInfo(ctx, connInfo) - if err != nil { - return nil, fmt.Errorf("failed to get stats info: %w", err) - } - - telemetry, err := extractTelemetryFromStats(statsInfo) - if err != nil { - return nil, fmt.Errorf("failed to extract telemetry from stats: %w", err) - } - - // Convert GH/s to H/s for device-level hashrate from RPC summary - hashrateHS := summary.GHS5s * GHSToHS - telemetry.HashrateHS = &hashrateHS - - // Try to get power from RPC stats (best-effort; not all firmware versions report it) - rpcStatsResp, err := c.GetStats(ctx) - if err != nil { - slog.Debug("failed to get RPC stats for power data", "error", err) - } else { - telemetry.PowerWatts = parseWattageFromRPCStats(rpcStatsResp) - } - - // Compute efficiency (J/H) if we have both power and hashrate - if telemetry.PowerWatts != nil && hashrateHS > 0 { - efficiency := *telemetry.PowerWatts / hashrateHS - telemetry.EfficiencyJPerHash = &efficiency - } - - return telemetry, nil -} - -// parseWattageFromRPCStats extracts power (watts) from the RPC stats response. -// The STATS array's second element (index 1) contains the mining stats, which -// may include power data as "chain_power" (string like "3250 W") or "power"/"Power" (numeric). -// Returns nil if power data is not available (older firmware). -func parseWattageFromRPCStats(resp *rpc.StatsResponse) *float64 { - if resp == nil || len(resp.Stats) < 2 { - return nil - } - - var statsData map[string]json.RawMessage - if err := json.Unmarshal(resp.Stats[1], &statsData); err != nil { - return nil - } - - // Try chain_power first (string "3250 W" or "3250.00" format) - if raw, ok := statsData["chain_power"]; ok { - var s string - if json.Unmarshal(raw, &s) == nil { - parts := strings.Fields(s) - if len(parts) > 0 { - if watts, err := strconv.ParseFloat(parts[0], 64); err == nil && watts > 0 { - return &watts - } - } - } - // Also try as a bare number - var watts float64 - if json.Unmarshal(raw, &watts) == nil && watts > 0 { - return &watts - } - } - - // Fallback to numeric "power" or "Power" field - for _, key := range []string{"power", "Power"} { - if raw, ok := statsData[key]; ok { - var watts float64 - if json.Unmarshal(raw, &watts) == nil && watts > 0 { - return &watts - } - } - } - - return nil -} - -// Pair performs device pairing (authentication setup) -func (c *Client) Pair(ctx context.Context, credentials sdk.UsernamePassword) error { - err := c.SetCredentials(credentials) - if err != nil { - return fmt.Errorf("failed to set credentials: %w", err) - } - - _, err = c.webClient.GetSystemInfo(ctx, c.getWebConnectionInfo()) // Test if credentials are valid - if err != nil { - return fmt.Errorf("failed to pair with device: %w", err) - } - - return nil -} - -// StartMining starts mining operations -func (c *Client) StartMining(ctx context.Context) error { - return c.setWorkMode(ctx, web.BitmainWorkModeStart) -} - -// StopMining stops mining operations by putting the device into sleep mode. -func (c *Client) StopMining(ctx context.Context) error { - return c.setWorkMode(ctx, web.BitmainWorkModeSleep) -} - -// setWorkMode fetches the current miner config, updates the work mode, and applies it. -// -// Older Antminer firmware uses a "miner-mode" field; newer firmware uses -// "bitmain-work-mode". Both encode the same values ("0" = normal, "1" = sleep). -// We detect which field the device uses at runtime by checking which one is -// non-empty in the GET response, matching the ASIC-RS approach. -func (c *Client) setWorkMode(ctx context.Context, mode web.BitmainWorkMode) error { - connInfo := c.getWebConnectionInfo() - config, err := c.webClient.GetMinerConfig(ctx, connInfo) - if err != nil { - return fmt.Errorf("failed to get current miner config: %w", err) - } - // Legacy devices return "miner-mode"; modern devices return "bitmain-work-mode". - // Update whichever field the device reported so we don't clobber the wrong one. - if config.MinerMode != "" { - config.MinerMode = string(mode) - } else { - config.BitmainWorkMode = mode - } - return c.webClient.SetMinerConfig(ctx, connInfo, config) -} - -// SetCoolingMode sets the cooling mode -func (c *Client) SetCoolingMode(_ context.Context, _ web.CoolingMode) error { - return fmt.Errorf("cooling mode control is not supported for antminer devices") -} - -// UpdatePools updates mining pool configuration -func (c *Client) UpdatePools(ctx context.Context, pools []Pool) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for pool configuration") - } - - connInfo := c.getWebConnectionInfo() - - // Get current config - config, err := c.webClient.GetMinerConfig(ctx, connInfo) - if err != nil { - return fmt.Errorf("failed to get current config: %w", err) - } - - // Convert pools to web API format - webPools := make([]web.Pool, len(pools)) - for i, pool := range pools { - webPools[i] = web.Pool{ - URL: pool.URL, - Username: pool.WorkerName, - } - } - - // Update pools in config - config.Pools = webPools - - // Set the updated config - return c.webClient.SetMinerConfig(ctx, connInfo, config) -} - -// GetMinerConfig retrieves the current miner configuration -func (c *Client) GetMinerConfig(ctx context.Context) (*web.MinerConfig, error) { - if c.credentials == nil { - return nil, fmt.Errorf("credentials required for miner configuration") - } - - connInfo := c.getWebConnectionInfo() - return c.webClient.GetMinerConfig(ctx, connInfo) -} - -// SetMinerConfig updates the miner configuration -func (c *Client) SetMinerConfig(ctx context.Context, config *web.MinerConfig) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for miner configuration") - } - - connInfo := c.getWebConnectionInfo() - return c.webClient.SetMinerConfig(ctx, connInfo, config) -} - -// BlinkLED triggers LED identification -func (c *Client) BlinkLED(ctx context.Context, duration time.Duration) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for LED blink") - } - - connInfo := c.getWebConnectionInfo() - - // Start blinking - if err := c.webClient.StartBlink(ctx, connInfo); err != nil { - return fmt.Errorf("failed to start LED blink: %w", err) - } - - if !c.stopBlinkMux.TryLock() { - return fmt.Errorf("LED is already blinking") - } - time.AfterFunc(duration, func() { - // Stop blinking after duration - _ = c.webClient.StopBlink(context.Background(), connInfo) - c.stopBlinkMux.Unlock() - }) - - return nil -} - -// GetLogs retrieves device logs from the kernel log endpoint. -// The since parameter is not used as Antminer doesn't support time-based filtering. -// The maxLines parameter is not used as Antminer returns the full log. -// Returns the log content, a boolean indicating if there are more logs (always false for Antminer), -// and any error encountered. -func (c *Client) GetLogs(ctx context.Context, _ *time.Time, _ int) (string, bool, error) { - if c.credentials == nil { - return "", false, fmt.Errorf("credentials required for log download") - } - - connInfo := c.getWebConnectionInfo() - logs, err := c.webClient.GetKernelLog(ctx, connInfo) - if err != nil { - return "", false, fmt.Errorf("failed to get kernel log: %w", err) - } - - return logs, false, nil -} - -// Reboot reboots the device -func (c *Client) Reboot(ctx context.Context) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for reboot") - } - - connInfo := c.getWebConnectionInfo() - return c.webClient.Reboot(ctx, connInfo) -} - -// ChangePassword updates the miner web UI password -func (c *Client) ChangePassword(ctx context.Context, currentPassword, newPassword string) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for password change") - } - - connInfo := c.getWebConnectionInfo() - if err := c.webClient.ChangePassword(ctx, connInfo, currentPassword, newPassword); err != nil { - return fmt.Errorf("failed to change password: %w", err) - } - - // Update stored credentials with new password - c.credentials.Password = newPassword - c.connectInfo.Creds.Password = newPassword - - return nil -} - -// Deprecated: use UploadFirmware instead. -func (c *Client) UpdateFirmware(ctx context.Context) error { - return fmt.Errorf("firmware update not implemented for Antminer") -} - -// UploadFirmware uploads a firmware file to the Antminer via the CGI upgrade endpoint. -func (c *Client) UploadFirmware(ctx context.Context, firmware sdk.FirmwareFile) error { - if c.credentials == nil { - return fmt.Errorf("credentials required for firmware upload") - } - - connInfo := c.getWebConnectionInfo() - return c.webClient.UploadFirmware(ctx, connInfo, firmware) -} - -func (c *Client) SetCredentials(creds sdk.UsernamePassword) error { - c.credentials = &creds - // Update the connectInfo credentials as well - if c.connectInfo != nil { - c.connectInfo.Creds = creds - } - return nil -} - -// RPC methods - delegate to the RPC client - -// GetVersion gets version information via RPC -func (c *Client) GetVersion(ctx context.Context) (*rpc.VersionResponse, error) { - connInfo, err := c.getRPCConnectionInfo() - if err != nil { - return nil, err - } - return c.rpcClient.GetVersion(ctx, connInfo) -} - -// GetSummary gets mining summary information via RPC -func (c *Client) GetSummary(ctx context.Context) (*rpc.SummaryResponse, error) { - connInfo, err := c.getRPCConnectionInfo() - if err != nil { - return nil, err - } - resp, err := c.rpcClient.GetSummary(ctx, connInfo) - if err != nil { - return nil, err - } - return resp, nil -} - -// GetDevs gets device (ASIC) information via RPC -func (c *Client) GetDevs(ctx context.Context) (*rpc.DevsResponse, error) { - connInfo, err := c.getRPCConnectionInfo() - if err != nil { - return nil, err - } - resp, err := c.rpcClient.GetDevs(ctx, connInfo) - if err != nil { - return nil, err - } - return resp, nil -} - -// GetPools gets pool information via RPC -func (c *Client) GetPools(ctx context.Context) (*rpc.PoolsResponse, error) { - connInfo, err := c.getRPCConnectionInfo() - if err != nil { - return nil, err - } - return c.rpcClient.GetPools(ctx, connInfo) -} - -// GetStats gets mining stats via RPC (includes power data on supported firmware) -func (c *Client) GetStats(ctx context.Context) (*rpc.StatsResponse, error) { - connInfo, err := c.getRPCConnectionInfo() - if err != nil { - return nil, err - } - return c.rpcClient.GetStats(ctx, connInfo) -} - -// GetStatsInfo gets comprehensive stats via Web API -func (c *Client) GetStatsInfo(ctx context.Context) (*web.StatsInfo, error) { - if c.credentials == nil { - return nil, fmt.Errorf("credentials required for stats info") - } - - connInfo := c.getWebConnectionInfo() - return c.webClient.GetStatsInfo(ctx, connInfo) -} diff --git a/plugin/antminer/pkg/antminer/client_test.go b/plugin/antminer/pkg/antminer/client_test.go deleted file mode 100644 index 3f73a3ea0a..0000000000 --- a/plugin/antminer/pkg/antminer/client_test.go +++ /dev/null @@ -1,900 +0,0 @@ -package antminer - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc/mocks" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - webmocks "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web/mocks" - "github.com/block/proto-fleet/server/sdk/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" -) - -func TestNewClient(t *testing.T) { - client, err := NewClient("192.168.1.100", 4028, 80, "http") - require.NoError(t, err) - require.NotNil(t, client) - - assert.Equal(t, "192.168.1.100", client.host) - assert.Equal(t, int32(4028), client.rpcPort) - assert.Equal(t, int32(80), client.webPort) - assert.Equal(t, "http", client.urlScheme) - assert.NotNil(t, client.httpClient) - assert.NotNil(t, client.webClient) -} - -func TestClient_SetCredentials(t *testing.T) { - client, err := NewClient("192.168.1.100", 4028, 80, "http") - require.NoError(t, err) - - err = client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - require.NotNil(t, client.credentials) - assert.Equal(t, "admin", client.credentials.Username) - assert.Equal(t, "password", client.credentials.Password) -} - -// Helper functions for test setup -func createTestClient(t *testing.T) *Client { - client, err := NewClient("192.168.1.100", 4028, 80, "http") - require.NoError(t, err) - return client -} - -func createTestClientWithMocks(t *testing.T, webClient web.WebAPIClient, rpcClient rpc.RPCClient) *Client { - client, err := NewClient("192.168.1.100", 4028, 80, "http") - require.NoError(t, err) - - // Inject mock clients directly - if webClient != nil { - client.webClient = webClient - } - if rpcClient != nil { - client.rpcClient = rpcClient - } - - return client -} - -func setupMockWebClient(t *testing.T) (*webmocks.MockWebAPIClient, *gomock.Controller) { - ctrl := gomock.NewController(t) - mockWebClient := webmocks.NewMockWebAPIClient(ctrl) - return mockWebClient, ctrl -} - -func setupMockRPCClient(t *testing.T) (*mocks.MockRPCClient, *gomock.Controller) { - ctrl := gomock.NewController(t) - mockRPCClient := mocks.NewMockRPCClient(ctrl) - return mockRPCClient, ctrl -} - -func TestClient_UpdatePools(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - pools := []Pool{ - { - Priority: 1, - URL: "stratum+tcp://pool.example.com:4444", - WorkerName: "worker1", - }, - } - - // Test without credentials - should fail - err := client.UpdatePools(t.Context(), pools) - require.Error(t, err) - assert.Contains(t, err.Error(), "credentials required") - - // Test with credentials - should succeed - err = client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock successful config operations - mockConfig := &web.MinerConfig{ - Pools: []web.Pool{}, - } - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(mockConfig, nil) - - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - err = client.UpdatePools(t.Context(), pools) - require.NoError(t, err) - - // Test error case: config fetch fails - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("config fetch failed")) - - err = client.UpdatePools(t.Context(), pools) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get current config") -} - -func TestClient_BlinkLED(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - // Test without credentials - should fail - err := client.BlinkLED(t.Context(), 5*time.Second) - require.Error(t, err) - assert.Contains(t, err.Error(), "credentials required") - - // Test with credentials - should succeed - err = client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock successful blink operations - mockWebClient.EXPECT(). - StartBlink(gomock.Any(), gomock.Any()). - Return(nil) - - mockWebClient.EXPECT(). - StopBlink(gomock.Any(), gomock.Any()). - Return(nil). - AnyTimes() - - err = client.BlinkLED(t.Context(), 100*time.Millisecond) - require.NoError(t, err) - - // Test StartBlink API error - mockWebClient.EXPECT(). - StartBlink(gomock.Any(), gomock.Any()). - Return(fmt.Errorf("blink failed")) - - err = client.BlinkLED(t.Context(), 100*time.Millisecond) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to start LED blink") -} - -func TestClient_Reboot(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - // Test without credentials - err := client.Reboot(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "credentials required") - - // Test with credentials and successful mock response - err = client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock the Reboot call - mockWebClient.EXPECT(). - Reboot(gomock.Any(), gomock.Any()). - Return(nil) - - err = client.Reboot(t.Context()) - require.NoError(t, err) -} - -func TestClient_NotImplementedMethods(t *testing.T) { - client := createTestClient(t) - - err := client.UpdateFirmware(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "not implemented") -} - -func TestClient_GetLogs_NoCredentials(t *testing.T) { - client := createTestClient(t) - - _, _, err := client.GetLogs(t.Context(), nil, 0) - require.Error(t, err) - assert.Contains(t, err.Error(), "credentials required") -} - -func TestClient_GetLogs_Success(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - err := client.SetCredentials(sdk.UsernamePassword{Username: "root", Password: "root"}) - require.NoError(t, err) - - expectedLogs := "kernel log content here\nmore log lines" - mockWebClient.EXPECT(). - GetKernelLog(gomock.Any(), gomock.Any()). - Return(expectedLogs, nil) - - logs, hasMore, err := client.GetLogs(t.Context(), nil, 0) - require.NoError(t, err) - assert.Equal(t, expectedLogs, logs) - assert.False(t, hasMore) -} - -func TestClient_GetLogs_WebAPIError(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - err := client.SetCredentials(sdk.UsernamePassword{Username: "root", Password: "root"}) - require.NoError(t, err) - - mockWebClient.EXPECT(). - GetKernelLog(gomock.Any(), gomock.Any()). - Return("", fmt.Errorf("connection timeout")) - - _, _, err = client.GetLogs(t.Context(), nil, 0) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get kernel log") -} - -func TestClient_GetDeviceInfo(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - - // Mock the GetVersion RPC call - mockVersionResponse := &rpc.VersionResponse{ - Version: []rpc.VersionInfo{ - { - Miner: "uart_trans.1.3", - Type: "Antminer S19", - BMMiner: "1.0.0", - }, - }, - } - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - // Test without credentials (no web API call) - deviceInfo, err := client.GetDeviceInfo(t.Context()) - require.NoError(t, err) - assert.Equal(t, "Antminer S19", deviceInfo.Model) - assert.Equal(t, "Bitmain", deviceInfo.Manufacturer) - assert.Equal(t, "", deviceInfo.SerialNumber) // No credentials, so no web API call - assert.Equal(t, "", deviceInfo.MacAddress) - - // Test with credentials (includes web API call) - err = client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock the GetVersion RPC call again - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - // Mock the GetSystemInfo web API call - mockSystemInfo := &web.SystemInfo{ - SerialNumber: "ABC123456", - MacAddr: "00:11:22:33:44:55", - } - mockWebClient.EXPECT(). - GetSystemInfo(gomock.Any(), gomock.Any()). - Return(mockSystemInfo, nil) - - deviceInfo, err = client.GetDeviceInfo(t.Context()) - require.NoError(t, err) - assert.Equal(t, "Antminer S19", deviceInfo.Model) - assert.Equal(t, "Bitmain", deviceInfo.Manufacturer) - assert.Equal(t, "ABC123456", deviceInfo.SerialNumber) - assert.Equal(t, "00:11:22:33:44:55", deviceInfo.MacAddress) -} - -func TestClient_GetStatus(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - defer rpcCtrl.Finish() - - client := createTestClientWithMocks(t, nil, mockRPCClient) - - // Mock the GetSummary RPC call - mockSummaryResponse := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - GHS5s: 100.5, - HardwareErrors: 0, - }, - }, - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryResponse, nil) - - // Mock the GetVersion RPC call for firmware version - mockVersionResponse := &rpc.VersionResponse{ - Version: []rpc.VersionInfo{ - { - BMMiner: "1.0.0", - }, - }, - } - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - status, err := client.GetStatus(t.Context()) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyActive, status.State) - assert.Equal(t, "", status.ErrorMessage) - assert.Equal(t, "1.0.0", status.FirmwareVersion) - - // Test with zero hashrate (inactive). - // Note: HardwareErrors is a cumulative counter and should NOT affect health status. - // A device with accumulated errors but zero hashrate is simply inactive, not critical. - mockSummaryInactive := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - GHS5s: 0, - HardwareErrors: 5, - }, - }, - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryInactive, nil) - - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - status, err = client.GetStatus(t.Context()) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyInactive, status.State) - assert.Empty(t, status.ErrorMessage) - - // Test with hardware errors but active hashrate (device is healthy despite accumulated errors) - mockSummaryActiveWithErrors := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - GHS5s: 150.0, - HardwareErrors: 100, - }, - }, - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryActiveWithErrors, nil) - - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - status, err = client.GetStatus(t.Context()) - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyActive, status.State) - assert.Empty(t, status.ErrorMessage) -} - -func TestClient_GetStatus_WorkModeCheck(t *testing.T) { - tests := []struct { - name string - workMode web.BitmainWorkMode - legacyMode string - expectedState sdk.HealthStatus - }{ - { - name: "zero hashrate in sleep mode is inactive", - workMode: web.BitmainWorkModeSleep, - expectedState: sdk.HealthHealthyInactive, - }, - { - name: "zero hashrate in normal mode is warning", - workMode: web.BitmainWorkModeStart, - expectedState: sdk.HealthWarning, - }, - { - name: "zero hashrate with legacy miner-mode sleep is inactive", - legacyMode: string(web.BitmainWorkModeSleep), - expectedState: sdk.HealthHealthyInactive, - }, - { - name: "zero hashrate with legacy miner-mode normal is warning", - legacyMode: string(web.BitmainWorkModeStart), - expectedState: sdk.HealthWarning, - }, - { - name: "zero hashrate in low power mode is warning", - workMode: web.BitmainWorkModeLowPower, - expectedState: sdk.HealthWarning, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Arrange - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - err := client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{{GHS5s: 0}}, - }, nil) - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(&rpc.VersionResponse{Version: []rpc.VersionInfo{{BMMiner: "1.0.0"}}}, nil) - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(&web.MinerConfig{ - BitmainWorkMode: tc.workMode, - MinerMode: tc.legacyMode, - }, nil) - - // Act - status, err := client.GetStatus(t.Context()) - - // Assert - require.NoError(t, err) - assert.Equal(t, tc.expectedState, status.State) - }) - } -} - -func TestClient_GetStatus_WorkModeCheckFallback(t *testing.T) { - // When GetMinerConfig fails, GetStatus should fall back to HealthHealthyInactive - // rather than propagating the error, since the hashrate check already determined - // the device is not actively mining. - - // Arrange - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - err := client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(&rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{{GHS5s: 0}}, - }, nil) - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(&rpc.VersionResponse{Version: []rpc.VersionInfo{{BMMiner: "1.0.0"}}}, nil) - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("connection refused")) - - // Act - status, err := client.GetStatus(t.Context()) - - // Assert - require.NoError(t, err) - assert.Equal(t, sdk.HealthHealthyInactive, status.State) -} - -func TestClient_GetTelemetry(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - mockWebClient, webCtrl := setupMockWebClient(t) - defer rpcCtrl.Finish() - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, mockRPCClient) - - // Set credentials first (required for stats.cgi) - err := client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock the GetSummary RPC call for hashrate - mockSummaryResponse := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{ - { - GHS5s: 100.5, - Elapsed: 3600, - }, - }, - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryResponse, nil) - - // Mock the GetStatsInfo web API call for temperature and component metrics - mockStatsInfo := &web.StatsInfo{ - STATUS: web.StatsStatus{ - Status: "S", - When: 1234567890, - Msg: "stats", - APIVersion: "1.0.0", - }, - INFO: web.StatsMinerInfo{ - MinerVersion: "uart_trans.1.3", - CompileTime: "Thu Jul 11 16:38:25 CST 2024", - Type: "Antminer S21", - }, - STATS: []web.StatsData{ - { - Elapsed: 3600, - Rate5s: 100500.0, // GH/s - ChainNum: 3, - FanNum: 4, - Fan: []int{7000, 7100, 7200, 7300}, - HWPTotal: 0.0006, - Chain: []web.ChainStats{ - { - Index: 0, - FreqAvg: 490, - RateReal: 33500.0, - ASICNum: 108, - TempChip: []float64{59.0, 59.0, 73.0, 73.0}, // [inlet_1, inlet_2, outlet_1, outlet_2] - HW: 0, - SN: "SMTTYRHBDJAAI019D", - }, - { - Index: 1, - FreqAvg: 490, - RateReal: 33500.0, - ASICNum: 108, - TempChip: []float64{61.0, 61.0, 75.0, 75.0}, - HW: 0, - SN: "SMTTYRHBDJAAI019E", - }, - { - Index: 2, - FreqAvg: 490, - RateReal: 33500.0, - ASICNum: 108, - TempChip: []float64{63.0, 63.0, 77.0, 77.0}, - HW: 0, - SN: "SMTTYRHBDJAAI019F", - }, - }, - }, - }, - } - mockWebClient.EXPECT(). - GetStatsInfo(gomock.Any(), gomock.Any()). - Return(mockStatsInfo, nil) - - // Mock the RPC stats call for power data - mockStatsResponse := &rpc.StatsResponse{ - Status: []rpc.StatusInfo{{Status: "S"}}, - Stats: []json.RawMessage{ - json.RawMessage(`{"BMMiner":"1.0.0","Type":"Antminer S21"}`), - json.RawMessage(`{"STATS":0,"ID":"BTM_SOC0","chain_power":"3250 W"}`), - }, - } - mockRPCClient.EXPECT(). - GetStats(gomock.Any(), gomock.Any()). - Return(mockStatsResponse, nil) - - telemetry, err := client.GetTelemetry(t.Context()) - require.NoError(t, err) - - // Verify device-level metrics - expectedHashrate := 100.5 * GHSToHS - assert.InEpsilon(t, expectedHashrate, *telemetry.HashrateHS, 0.01) - assert.Equal(t, int64(3600), *telemetry.UptimeSeconds) - - // Verify temperature (max of all temp_chip values: 77.0°C) - assert.InEpsilon(t, 77.0, *telemetry.TemperatureCelsius, 0.01) - - // Verify fan speed (max of all fans: 7300 RPM) - assert.InEpsilon(t, 7300.0, *telemetry.FanRPM, 0.01) - - // Verify hardware error rate - assert.InEpsilon(t, 0.0006, *telemetry.HardwareErrorRate, 0.0001) - - // Verify component-level metrics - require.Len(t, telemetry.HashBoards, 3) - require.Len(t, telemetry.Fans, 4) - - // Verify first hashboard - assert.Equal(t, 0, telemetry.HashBoards[0].Index) - assert.Equal(t, "SMTTYRHBDJAAI019D", telemetry.HashBoards[0].SerialNumber) - assert.InEpsilon(t, 73.0, *telemetry.HashBoards[0].Temperature, 0.01) // max of temp_chip - assert.InEpsilon(t, 59.0, *telemetry.HashBoards[0].InletTemp, 0.01) // avg of first 2 - assert.InEpsilon(t, 73.0, *telemetry.HashBoards[0].OutletTemp, 0.01) // avg of last 2 - assert.Equal(t, 108, telemetry.HashBoards[0].ChipCount) - assert.Equal(t, 490, telemetry.HashBoards[0].ChipFrequencyMHz) - - // Verify fans - assert.Equal(t, 0, telemetry.Fans[0].Index) - assert.Equal(t, 7000, telemetry.Fans[0].RPM) - assert.Equal(t, 3, telemetry.Fans[3].Index) - assert.Equal(t, 7300, telemetry.Fans[3].RPM) - - // Verify power and efficiency - require.NotNil(t, telemetry.PowerWatts, "PowerWatts should be set from RPC stats") - assert.InDelta(t, 3250.0, *telemetry.PowerWatts, 0.01) - - require.NotNil(t, telemetry.EfficiencyJPerHash, "EfficiencyJPerHash should be computed") - expectedEfficiency := 3250.0 / expectedHashrate // J/H = W / (H/s) - assert.InEpsilon(t, expectedEfficiency, *telemetry.EfficiencyJPerHash, 0.01) -} - -func TestClient_Pair(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - creds := sdk.UsernamePassword{Username: "admin", Password: "password"} - - // Mock successful system info call for pairing validation - mockSystemInfo := &web.SystemInfo{ - SerialNumber: "ABC123456", - MacAddr: "00:11:22:33:44:55", - } - mockWebClient.EXPECT(). - GetSystemInfo(gomock.Any(), gomock.Any()). - Return(mockSystemInfo, nil) - - err := client.Pair(t.Context(), creds) - require.NoError(t, err) - - // Verify credentials were set - assert.Equal(t, "admin", client.credentials.Username) - assert.Equal(t, "password", client.credentials.Password) -} - -func TestClient_StartStopMining(t *testing.T) { - mockWebClient, webCtrl := setupMockWebClient(t) - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - baseConfig := &web.MinerConfig{ - Pools: []web.Pool{{URL: "stratum+tcp://pool.example.com:3333", Username: "worker", Password: "x"}}, - BitmainWorkMode: web.BitmainWorkModeStart, - } - - // Test StartMining - should fetch config and set work mode to "0" - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(baseConfig, nil) - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, _ any, config *web.MinerConfig) error { - assert.Equal(t, web.BitmainWorkModeStart, config.BitmainWorkMode) - assert.Equal(t, baseConfig.Pools, config.Pools) // pools preserved - return nil - }) - - err := client.StartMining(t.Context()) - require.NoError(t, err) - - // Test StopMining - should fetch config and set work mode to "1" (sleep) - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(baseConfig, nil) - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, _ any, config *web.MinerConfig) error { - assert.Equal(t, web.BitmainWorkModeSleep, config.BitmainWorkMode) - assert.Equal(t, baseConfig.Pools, config.Pools) // pools preserved - return nil - }) - - err = client.StopMining(t.Context()) - require.NoError(t, err) - - // Test StartMining when GetMinerConfig fails - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("config fetch failed")) - - err = client.StartMining(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get current miner config") - - // Test StartMining when SetMinerConfig fails - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(baseConfig, nil) - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - Return(fmt.Errorf("API error")) - - err = client.StartMining(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "API error") -} - -func TestClient_StartStopMining_LegacyMinerMode(t *testing.T) { - // Older Antminer firmware uses "miner-mode" instead of "bitmain-work-mode". - // setWorkMode should detect which field the device uses and update that one. - mockWebClient, webCtrl := setupMockWebClient(t) - defer webCtrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - legacyConfig := &web.MinerConfig{ - Pools: []web.Pool{{URL: "stratum+tcp://pool.example.com:3333", Username: "worker", Password: "x"}}, - MinerMode: string(web.BitmainWorkModeStart), // legacy device reports "miner-mode", not "bitmain-work-mode" - } - - // Test StopMining — should update MinerMode, not BitmainWorkMode - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(legacyConfig, nil) - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, _ any, config *web.MinerConfig) error { - // Act (done by the caller) - // Assert - assert.Equal(t, string(web.BitmainWorkModeSleep), config.MinerMode) - assert.Equal(t, web.BitmainWorkMode(""), config.BitmainWorkMode) // not touched - assert.Equal(t, legacyConfig.Pools, config.Pools) // pools preserved - return nil - }) - - err := client.StopMining(t.Context()) - require.NoError(t, err) - - // Test StartMining — should update MinerMode, not BitmainWorkMode - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(&web.MinerConfig{ - MinerMode: string(web.BitmainWorkModeSleep), - }, nil) - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, _ any, config *web.MinerConfig) error { - // Act (done by the caller) - // Assert - assert.Equal(t, string(web.BitmainWorkModeStart), config.MinerMode) - assert.Equal(t, web.BitmainWorkMode(""), config.BitmainWorkMode) - return nil - }) - - err = client.StartMining(t.Context()) - require.NoError(t, err) -} - -func TestClient_ErrorCases(t *testing.T) { - t.Run("GetDeviceInfo_NoVersionInfo", func(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - defer rpcCtrl.Finish() - - client := createTestClientWithMocks(t, nil, mockRPCClient) - - // Mock empty version response - mockVersionResponse := &rpc.VersionResponse{ - Version: []rpc.VersionInfo{}, // Empty version info - } - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(mockVersionResponse, nil) - - _, err := client.GetDeviceInfo(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "no version information available") - }) - - t.Run("GetDeviceInfo_RPCFailure", func(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - defer rpcCtrl.Finish() - - client := createTestClientWithMocks(t, nil, mockRPCClient) - - // Mock RPC failure - mockRPCClient.EXPECT(). - GetVersion(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("RPC connection failed")) - - _, err := client.GetDeviceInfo(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get version info") - assert.Contains(t, err.Error(), "RPC connection failed") - }) - - t.Run("GetStatus_NoSummaryInfo", func(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - defer rpcCtrl.Finish() - - client := createTestClientWithMocks(t, nil, mockRPCClient) - - // Mock empty summary response - mockSummaryResponse := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{}, // Empty summary info - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryResponse, nil) - - _, err := client.GetStatus(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "no summary information available") - }) - - t.Run("GetTelemetry_NoSummaryInfo", func(t *testing.T) { - mockRPCClient, rpcCtrl := setupMockRPCClient(t) - defer rpcCtrl.Finish() - - client := createTestClientWithMocks(t, nil, mockRPCClient) - - // Mock empty summary response - mockSummaryResponse := &rpc.SummaryResponse{ - Summary: []rpc.SummaryInfo{}, // Empty summary info - } - mockRPCClient.EXPECT(). - GetSummary(gomock.Any(), gomock.Any()). - Return(mockSummaryResponse, nil) - - _, err := client.GetTelemetry(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "no summary information available") - }) - - t.Run("UpdatePools_EmptyPoolList", func(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - // Set credentials - err := client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock successful config operations - mockConfig := &web.MinerConfig{ - Pools: []web.Pool{}, - } - mockWebClient.EXPECT(). - GetMinerConfig(gomock.Any(), gomock.Any()). - Return(mockConfig, nil) - - mockWebClient.EXPECT(). - SetMinerConfig(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) - - // Test with empty pool list - should succeed - err = client.UpdatePools(t.Context(), []Pool{}) - require.NoError(t, err) - }) -} - -func TestClient_BlinkLED_ConcurrentCalls(t *testing.T) { - mockWebClient, ctrl := setupMockWebClient(t) - defer ctrl.Finish() - - client := createTestClientWithMocks(t, mockWebClient, nil) - - // Set credentials first - err := client.SetCredentials(sdk.UsernamePassword{Username: "admin", Password: "password"}) - require.NoError(t, err) - - // Mock blink operations - mockWebClient.EXPECT(). - StartBlink(gomock.Any(), gomock.Any()). - Return(nil). - AnyTimes() - - mockWebClient.EXPECT(). - StopBlink(gomock.Any(), gomock.Any()). - Return(nil). - AnyTimes() - - // Start first blink - err = client.BlinkLED(t.Context(), 100*time.Millisecond) - require.NoError(t, err) - - // Immediately try to start second blink - should fail - err = client.BlinkLED(t.Context(), 100*time.Millisecond) - require.Error(t, err) - assert.Contains(t, err.Error(), "LED is already blinking") -} diff --git a/plugin/antminer/pkg/antminer/interface.go b/plugin/antminer/pkg/antminer/interface.go deleted file mode 100644 index fad9877a38..0000000000 --- a/plugin/antminer/pkg/antminer/interface.go +++ /dev/null @@ -1,52 +0,0 @@ -package antminer - -import ( - "context" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - "github.com/block/proto-fleet/server/sdk/v1" -) - -// AntminerClient defines the interface for communicating with Antminer devices -// This interface allows for easy mocking and testing -// -//go:generate go run go.uber.org/mock/mockgen -source=interface.go -destination=mocks/mock_client.go -package=mocks AntminerClient -//nolint:interfacebloat // This interface represents a complete device client with necessary operations -type AntminerClient interface { - // RPC operations - GetVersion(ctx context.Context) (*rpc.VersionResponse, error) - GetSummary(ctx context.Context) (*rpc.SummaryResponse, error) - GetDevs(ctx context.Context) (*rpc.DevsResponse, error) - GetPools(ctx context.Context) (*rpc.PoolsResponse, error) - - // Web API operations - GetStatsInfo(ctx context.Context) (*web.StatsInfo, error) - - // High-level operations - GetDeviceInfo(ctx context.Context) (*DeviceInfo, error) - GetStatus(ctx context.Context) (*Status, error) - GetTelemetry(ctx context.Context) (*Telemetry, error) - GetLogs(ctx context.Context, since *time.Time, maxLines int) (string, bool, error) - Pair(ctx context.Context, credentials sdk.UsernamePassword) error - StopMining(ctx context.Context) error - StartMining(ctx context.Context) error - - // Configuration and management - SetCredentials(credentials sdk.UsernamePassword) error - SetCoolingMode(ctx context.Context, mode web.CoolingMode) error - UpdatePools(ctx context.Context, pools []Pool) error - GetMinerConfig(ctx context.Context) (*web.MinerConfig, error) - SetMinerConfig(ctx context.Context, config *web.MinerConfig) error - BlinkLED(ctx context.Context, duration time.Duration) error - Reboot(ctx context.Context) error - ChangePassword(ctx context.Context, currentPassword, newPassword string) error - UploadFirmware(ctx context.Context, firmware sdk.FirmwareFile) error - - // Lifecycle - Close() -} - -// Ensure Client implements AntminerClient -var _ AntminerClient = (*Client)(nil) diff --git a/plugin/antminer/pkg/antminer/mocks/mock_client.go b/plugin/antminer/pkg/antminer/mocks/mock_client.go deleted file mode 100644 index a4e4d42146..0000000000 --- a/plugin/antminer/pkg/antminer/mocks/mock_client.go +++ /dev/null @@ -1,363 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: interface.go -// -// Generated by this command: -// -// mockgen -source=interface.go -destination=mocks/mock_client.go -package=mocks AntminerClient -// - -// Package mocks is a generated GoMock package. -package mocks - -import ( - context "context" - reflect "reflect" - time "time" - - antminer "github.com/block/proto-fleet/plugin/antminer/pkg/antminer" - rpc "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - web "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdk "github.com/block/proto-fleet/server/sdk/v1" - gomock "go.uber.org/mock/gomock" -) - -// MockAntminerClient is a mock of AntminerClient interface. -type MockAntminerClient struct { - ctrl *gomock.Controller - recorder *MockAntminerClientMockRecorder - isgomock struct{} -} - -// MockAntminerClientMockRecorder is the mock recorder for MockAntminerClient. -type MockAntminerClientMockRecorder struct { - mock *MockAntminerClient -} - -// NewMockAntminerClient creates a new mock instance. -func NewMockAntminerClient(ctrl *gomock.Controller) *MockAntminerClient { - mock := &MockAntminerClient{ctrl: ctrl} - mock.recorder = &MockAntminerClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockAntminerClient) EXPECT() *MockAntminerClientMockRecorder { - return m.recorder -} - -// BlinkLED mocks base method. -func (m *MockAntminerClient) BlinkLED(ctx context.Context, duration time.Duration) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BlinkLED", ctx, duration) - ret0, _ := ret[0].(error) - return ret0 -} - -// BlinkLED indicates an expected call of BlinkLED. -func (mr *MockAntminerClientMockRecorder) BlinkLED(ctx, duration any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BlinkLED", reflect.TypeOf((*MockAntminerClient)(nil).BlinkLED), ctx, duration) -} - -// ChangePassword mocks base method. -func (m *MockAntminerClient) ChangePassword(ctx context.Context, currentPassword, newPassword string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ChangePassword", ctx, currentPassword, newPassword) - ret0, _ := ret[0].(error) - return ret0 -} - -// ChangePassword indicates an expected call of ChangePassword. -func (mr *MockAntminerClientMockRecorder) ChangePassword(ctx, currentPassword, newPassword any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangePassword", reflect.TypeOf((*MockAntminerClient)(nil).ChangePassword), ctx, currentPassword, newPassword) -} - -// Close mocks base method. -func (m *MockAntminerClient) Close() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Close") -} - -// Close indicates an expected call of Close. -func (mr *MockAntminerClientMockRecorder) Close() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockAntminerClient)(nil).Close)) -} - -// GetDeviceInfo mocks base method. -func (m *MockAntminerClient) GetDeviceInfo(ctx context.Context) (*antminer.DeviceInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDeviceInfo", ctx) - ret0, _ := ret[0].(*antminer.DeviceInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetDeviceInfo indicates an expected call of GetDeviceInfo. -func (mr *MockAntminerClientMockRecorder) GetDeviceInfo(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeviceInfo", reflect.TypeOf((*MockAntminerClient)(nil).GetDeviceInfo), ctx) -} - -// GetDevs mocks base method. -func (m *MockAntminerClient) GetDevs(ctx context.Context) (*rpc.DevsResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDevs", ctx) - ret0, _ := ret[0].(*rpc.DevsResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetDevs indicates an expected call of GetDevs. -func (mr *MockAntminerClientMockRecorder) GetDevs(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDevs", reflect.TypeOf((*MockAntminerClient)(nil).GetDevs), ctx) -} - -// GetLogs mocks base method. -func (m *MockAntminerClient) GetLogs(ctx context.Context, since *time.Time, maxLines int) (string, bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetLogs", ctx, since, maxLines) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(bool) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetLogs indicates an expected call of GetLogs. -func (mr *MockAntminerClientMockRecorder) GetLogs(ctx, since, maxLines any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogs", reflect.TypeOf((*MockAntminerClient)(nil).GetLogs), ctx, since, maxLines) -} - -// GetMinerConfig mocks base method. -func (m *MockAntminerClient) GetMinerConfig(ctx context.Context) (*web.MinerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMinerConfig", ctx) - ret0, _ := ret[0].(*web.MinerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMinerConfig indicates an expected call of GetMinerConfig. -func (mr *MockAntminerClientMockRecorder) GetMinerConfig(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinerConfig", reflect.TypeOf((*MockAntminerClient)(nil).GetMinerConfig), ctx) -} - -// GetPools mocks base method. -func (m *MockAntminerClient) GetPools(ctx context.Context) (*rpc.PoolsResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPools", ctx) - ret0, _ := ret[0].(*rpc.PoolsResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPools indicates an expected call of GetPools. -func (mr *MockAntminerClientMockRecorder) GetPools(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPools", reflect.TypeOf((*MockAntminerClient)(nil).GetPools), ctx) -} - -// GetStatsInfo mocks base method. -func (m *MockAntminerClient) GetStatsInfo(ctx context.Context) (*web.StatsInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetStatsInfo", ctx) - ret0, _ := ret[0].(*web.StatsInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetStatsInfo indicates an expected call of GetStatsInfo. -func (mr *MockAntminerClientMockRecorder) GetStatsInfo(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatsInfo", reflect.TypeOf((*MockAntminerClient)(nil).GetStatsInfo), ctx) -} - -// GetStatus mocks base method. -func (m *MockAntminerClient) GetStatus(ctx context.Context) (*antminer.Status, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetStatus", ctx) - ret0, _ := ret[0].(*antminer.Status) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetStatus indicates an expected call of GetStatus. -func (mr *MockAntminerClientMockRecorder) GetStatus(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAntminerClient)(nil).GetStatus), ctx) -} - -// GetSummary mocks base method. -func (m *MockAntminerClient) GetSummary(ctx context.Context) (*rpc.SummaryResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSummary", ctx) - ret0, _ := ret[0].(*rpc.SummaryResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetSummary indicates an expected call of GetSummary. -func (mr *MockAntminerClientMockRecorder) GetSummary(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockAntminerClient)(nil).GetSummary), ctx) -} - -// GetTelemetry mocks base method. -func (m *MockAntminerClient) GetTelemetry(ctx context.Context) (*antminer.Telemetry, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTelemetry", ctx) - ret0, _ := ret[0].(*antminer.Telemetry) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetTelemetry indicates an expected call of GetTelemetry. -func (mr *MockAntminerClientMockRecorder) GetTelemetry(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTelemetry", reflect.TypeOf((*MockAntminerClient)(nil).GetTelemetry), ctx) -} - -// GetVersion mocks base method. -func (m *MockAntminerClient) GetVersion(ctx context.Context) (*rpc.VersionResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetVersion", ctx) - ret0, _ := ret[0].(*rpc.VersionResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetVersion indicates an expected call of GetVersion. -func (mr *MockAntminerClientMockRecorder) GetVersion(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockAntminerClient)(nil).GetVersion), ctx) -} - -// Pair mocks base method. -func (m *MockAntminerClient) Pair(ctx context.Context, credentials sdk.UsernamePassword) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Pair", ctx, credentials) - ret0, _ := ret[0].(error) - return ret0 -} - -// Pair indicates an expected call of Pair. -func (mr *MockAntminerClientMockRecorder) Pair(ctx, credentials any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pair", reflect.TypeOf((*MockAntminerClient)(nil).Pair), ctx, credentials) -} - -// Reboot mocks base method. -func (m *MockAntminerClient) Reboot(ctx context.Context) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Reboot", ctx) - ret0, _ := ret[0].(error) - return ret0 -} - -// Reboot indicates an expected call of Reboot. -func (mr *MockAntminerClientMockRecorder) Reboot(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reboot", reflect.TypeOf((*MockAntminerClient)(nil).Reboot), ctx) -} - -// SetCoolingMode mocks base method. -func (m *MockAntminerClient) SetCoolingMode(ctx context.Context, mode web.CoolingMode) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetCoolingMode", ctx, mode) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetCoolingMode indicates an expected call of SetCoolingMode. -func (mr *MockAntminerClientMockRecorder) SetCoolingMode(ctx, mode any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCoolingMode", reflect.TypeOf((*MockAntminerClient)(nil).SetCoolingMode), ctx, mode) -} - -// SetCredentials mocks base method. -func (m *MockAntminerClient) SetCredentials(credentials sdk.UsernamePassword) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetCredentials", credentials) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetCredentials indicates an expected call of SetCredentials. -func (mr *MockAntminerClientMockRecorder) SetCredentials(credentials any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCredentials", reflect.TypeOf((*MockAntminerClient)(nil).SetCredentials), credentials) -} - -// SetMinerConfig mocks base method. -func (m *MockAntminerClient) SetMinerConfig(ctx context.Context, config *web.MinerConfig) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetMinerConfig", ctx, config) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetMinerConfig indicates an expected call of SetMinerConfig. -func (mr *MockAntminerClientMockRecorder) SetMinerConfig(ctx, config any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinerConfig", reflect.TypeOf((*MockAntminerClient)(nil).SetMinerConfig), ctx, config) -} - -// StartMining mocks base method. -func (m *MockAntminerClient) StartMining(ctx context.Context) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StartMining", ctx) - ret0, _ := ret[0].(error) - return ret0 -} - -// StartMining indicates an expected call of StartMining. -func (mr *MockAntminerClientMockRecorder) StartMining(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartMining", reflect.TypeOf((*MockAntminerClient)(nil).StartMining), ctx) -} - -// StopMining mocks base method. -func (m *MockAntminerClient) StopMining(ctx context.Context) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StopMining", ctx) - ret0, _ := ret[0].(error) - return ret0 -} - -// StopMining indicates an expected call of StopMining. -func (mr *MockAntminerClientMockRecorder) StopMining(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopMining", reflect.TypeOf((*MockAntminerClient)(nil).StopMining), ctx) -} - -// UpdatePools mocks base method. -func (m *MockAntminerClient) UpdatePools(ctx context.Context, pools []antminer.Pool) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdatePools", ctx, pools) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdatePools indicates an expected call of UpdatePools. -func (mr *MockAntminerClientMockRecorder) UpdatePools(ctx, pools any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePools", reflect.TypeOf((*MockAntminerClient)(nil).UpdatePools), ctx, pools) -} - -// UploadFirmware mocks base method. -func (m *MockAntminerClient) UploadFirmware(ctx context.Context, firmware sdk.FirmwareFile) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UploadFirmware", ctx, firmware) - ret0, _ := ret[0].(error) - return ret0 -} - -// UploadFirmware indicates an expected call of UploadFirmware. -func (mr *MockAntminerClientMockRecorder) UploadFirmware(ctx, firmware any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFirmware", reflect.TypeOf((*MockAntminerClient)(nil).UploadFirmware), ctx, firmware) -} diff --git a/plugin/antminer/pkg/antminer/networking/networking.go b/plugin/antminer/pkg/antminer/networking/networking.go deleted file mode 100644 index 4ff0a53388..0000000000 --- a/plugin/antminer/pkg/antminer/networking/networking.go +++ /dev/null @@ -1,98 +0,0 @@ -package networking - -import ( - "fmt" - "net" - "net/url" - "strconv" -) - -type IPAddress string - -func (ip IPAddress) String() string { - return string(ip) -} - -type Port uint16 - -func (p Port) String() string { - return strconv.Itoa(int(p)) -} - -type Protocol int - -// Protocol constants for network communication with miners -const ( - // ProtocolHTTP is used for unencrypted web communication with miners - ProtocolHTTP Protocol = iota - // ProtocolHTTPS is used for secure encrypted web communication with miners - ProtocolHTTPS - // ProtocolTCP is used for direct socket connections with miners - ProtocolTCP -) - -func (p Protocol) String() string { - switch p { - case ProtocolHTTP: - return "http" - case ProtocolHTTPS: - return "https" - case ProtocolTCP: - return "tcp" - default: - return "unknown" - } -} - -func ProtocolFromString(s string) (Protocol, error) { - switch s { - case "http": - return ProtocolHTTP, nil - case "https": - return ProtocolHTTPS, nil - case "tcp": - return ProtocolTCP, nil - default: - return Protocol(-1), fmt.Errorf("unsupported protocol: %s", s) - } -} - -type ConnectionInfo struct { - IPAddress IPAddress - Port Port - Protocol Protocol -} - -func NewConnectionInfo(ipAddress string, port string, protocol Protocol) (*ConnectionInfo, error) { - portInt, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("failed to convert port to int: %w", err) - } - if portInt < 0 || portInt > 65535 { - return nil, fmt.Errorf("port out of range: %d", portInt) - } - - return &ConnectionInfo{ - IPAddress: IPAddress(ipAddress), - Port: Port(portInt), - Protocol: protocol, - }, nil -} - -func (c ConnectionInfo) getHost() string { - if c.Port == 0 { - return string(c.IPAddress) - } - return net.JoinHostPort(string(c.IPAddress), c.Port.String()) -} - -func (c ConnectionInfo) GetURL() *url.URL { - return &url.URL{ - Scheme: c.Protocol.String(), - Host: c.getHost(), - } -} - -func (c ConnectionInfo) GetHostPort() *url.URL { - return &url.URL{Host: c.getHost()} -} diff --git a/plugin/antminer/pkg/antminer/rpc/mocks/mock_rpc_client.go b/plugin/antminer/pkg/antminer/rpc/mocks/mock_rpc_client.go deleted file mode 100644 index ac9e160ebc..0000000000 --- a/plugin/antminer/pkg/antminer/rpc/mocks/mock_rpc_client.go +++ /dev/null @@ -1,133 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: service.go -// -// Generated by this command: -// -// mockgen -source=service.go -destination=mocks/mock_rpc_client.go -package=mocks RPCClient -// - -// Package mocks is a generated GoMock package. -package mocks - -import ( - context "context" - reflect "reflect" - - networking "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" - rpc "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - gomock "go.uber.org/mock/gomock" -) - -// MockRPCClient is a mock of RPCClient interface. -type MockRPCClient struct { - ctrl *gomock.Controller - recorder *MockRPCClientMockRecorder - isgomock struct{} -} - -// MockRPCClientMockRecorder is the mock recorder for MockRPCClient. -type MockRPCClientMockRecorder struct { - mock *MockRPCClient -} - -// NewMockRPCClient creates a new mock instance. -func NewMockRPCClient(ctrl *gomock.Controller) *MockRPCClient { - mock := &MockRPCClient{ctrl: ctrl} - mock.recorder = &MockRPCClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRPCClient) EXPECT() *MockRPCClientMockRecorder { - return m.recorder -} - -// GetConfig mocks base method. -func (m *MockRPCClient) GetConfig(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.ConfigResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetConfig", ctx, connInfo) - ret0, _ := ret[0].(*rpc.ConfigResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetConfig indicates an expected call of GetConfig. -func (mr *MockRPCClientMockRecorder) GetConfig(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockRPCClient)(nil).GetConfig), ctx, connInfo) -} - -// GetDevs mocks base method. -func (m *MockRPCClient) GetDevs(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.DevsResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDevs", ctx, connInfo) - ret0, _ := ret[0].(*rpc.DevsResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetDevs indicates an expected call of GetDevs. -func (mr *MockRPCClientMockRecorder) GetDevs(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDevs", reflect.TypeOf((*MockRPCClient)(nil).GetDevs), ctx, connInfo) -} - -// GetPools mocks base method. -func (m *MockRPCClient) GetPools(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.PoolsResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPools", ctx, connInfo) - ret0, _ := ret[0].(*rpc.PoolsResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPools indicates an expected call of GetPools. -func (mr *MockRPCClientMockRecorder) GetPools(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPools", reflect.TypeOf((*MockRPCClient)(nil).GetPools), ctx, connInfo) -} - -// GetStats mocks base method. -func (m *MockRPCClient) GetStats(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.StatsResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetStats", ctx, connInfo) - ret0, _ := ret[0].(*rpc.StatsResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetStats indicates an expected call of GetStats. -func (mr *MockRPCClientMockRecorder) GetStats(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStats", reflect.TypeOf((*MockRPCClient)(nil).GetStats), ctx, connInfo) -} - -// GetSummary mocks base method. -func (m *MockRPCClient) GetSummary(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.SummaryResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSummary", ctx, connInfo) - ret0, _ := ret[0].(*rpc.SummaryResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetSummary indicates an expected call of GetSummary. -func (mr *MockRPCClientMockRecorder) GetSummary(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockRPCClient)(nil).GetSummary), ctx, connInfo) -} - -// GetVersion mocks base method. -func (m *MockRPCClient) GetVersion(ctx context.Context, connInfo *networking.ConnectionInfo) (*rpc.VersionResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetVersion", ctx, connInfo) - ret0, _ := ret[0].(*rpc.VersionResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetVersion indicates an expected call of GetVersion. -func (mr *MockRPCClientMockRecorder) GetVersion(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockRPCClient)(nil).GetVersion), ctx, connInfo) -} diff --git a/plugin/antminer/pkg/antminer/rpc/service.go b/plugin/antminer/pkg/antminer/rpc/service.go deleted file mode 100644 index e53f692cdd..0000000000 --- a/plugin/antminer/pkg/antminer/rpc/service.go +++ /dev/null @@ -1,147 +0,0 @@ -package rpc - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "net" - "time" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" -) - -// timeouts and max response size to prevent large responses from causing issues -const ( - DefaultDialTimeout = 10 * time.Second - DefaultReadTimeout = 30 * time.Second - MaxResponseSize = 1 << 20 // 1MB -) - -//go:generate go run go.uber.org/mock/mockgen -source=service.go -destination=mocks/mock_rpc_client.go -package=mocks RPCClient -type RPCClient interface { - GetSummary(ctx context.Context, connInfo *networking.ConnectionInfo) (*SummaryResponse, error) - GetPools(ctx context.Context, connInfo *networking.ConnectionInfo) (*PoolsResponse, error) - GetVersion(ctx context.Context, connInfo *networking.ConnectionInfo) (*VersionResponse, error) - GetDevs(ctx context.Context, connInfo *networking.ConnectionInfo) (*DevsResponse, error) - GetConfig(ctx context.Context, connInfo *networking.ConnectionInfo) (*ConfigResponse, error) - GetStats(ctx context.Context, connInfo *networking.ConnectionInfo) (*StatsResponse, error) -} - -var _ RPCClient = &Service{} - -type ServiceOption func(*Service) - -func WithDialTimeout(timeout time.Duration) ServiceOption { - return func(s *Service) { - s.dialTimeout = timeout - } -} - -func WithReadTimeout(timeout time.Duration) ServiceOption { - return func(s *Service) { - s.readTimeout = timeout - } -} - -type Service struct { - dialTimeout time.Duration - readTimeout time.Duration -} - -func NewService(opts ...ServiceOption) *Service { - service := &Service{ - dialTimeout: DefaultDialTimeout, - readTimeout: DefaultReadTimeout, - } - for _, opt := range opts { - opt(service) - } - return service -} - -func (s *Service) request(ctx context.Context, connInfo *networking.ConnectionInfo, cmd string, out any) error { - req := &RPCRequest{Command: cmd} - return s.executeRPCCommand(ctx, connInfo, req, out) -} - -func (s *Service) executeRPCCommand(ctx context.Context, connInfo *networking.ConnectionInfo, request *RPCRequest, out any) error { - address := connInfo.GetURL().Host - protocol := connInfo.Protocol.String() - dialer := &net.Dialer{Timeout: s.dialTimeout} - - conn, err := dialer.DialContext(ctx, protocol, address) - if err != nil { - return fmt.Errorf("failed to connect to %s: %w", address, err) - } - defer conn.Close() - - if err := conn.SetReadDeadline(time.Now().Add(s.readTimeout)); err != nil { - return fmt.Errorf("failed to set read deadline: %w", err) - } - - if err := json.NewEncoder(conn).Encode(request); err != nil { - return fmt.Errorf("failed to encode request: %w", err) - } - - // Use a limited reader to prevent reading more than MaxResponseSize - limitReader := io.LimitReader(conn, MaxResponseSize) - reader := bufio.NewReader(limitReader) - decoder := json.NewDecoder(reader) - decoder.UseNumber() - - if err := decoder.Decode(out); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - return nil -} - -func (s *Service) GetSummary(ctx context.Context, connInfo *networking.ConnectionInfo) (*SummaryResponse, error) { - var resp SummaryResponse - if err := s.request(ctx, connInfo, "summary", &resp); err != nil { - return nil, err - } - return &resp, nil -} - -func (s *Service) GetPools(ctx context.Context, connInfo *networking.ConnectionInfo) (*PoolsResponse, error) { - var resp PoolsResponse - if err := s.request(ctx, connInfo, "pools", &resp); err != nil { - return nil, err - } - return &resp, nil -} - -func (s *Service) GetVersion(ctx context.Context, connInfo *networking.ConnectionInfo) (*VersionResponse, error) { - var resp VersionResponse - if err := s.request(ctx, connInfo, "version", &resp); err != nil { - return nil, err - } - return &resp, nil -} - -func (s *Service) GetDevs(ctx context.Context, connInfo *networking.ConnectionInfo) (*DevsResponse, error) { - var resp DevsResponse - if err := s.request(ctx, connInfo, "devs", &resp); err != nil { - return nil, err - } - return &resp, nil -} - -func (s *Service) GetConfig(ctx context.Context, connInfo *networking.ConnectionInfo) (*ConfigResponse, error) { - var resp ConfigResponse - if err := s.request(ctx, connInfo, "config", &resp); err != nil { - return nil, err - } - return &resp, nil -} - -func (s *Service) GetStats(ctx context.Context, connInfo *networking.ConnectionInfo) (*StatsResponse, error) { - var resp StatsResponse - if err := s.request(ctx, connInfo, "stats", &resp); err != nil { - return nil, err - } - return &resp, nil -} diff --git a/plugin/antminer/pkg/antminer/rpc/service_test.go b/plugin/antminer/pkg/antminer/rpc/service_test.go deleted file mode 100644 index 02ce76f5dd..0000000000 --- a/plugin/antminer/pkg/antminer/rpc/service_test.go +++ /dev/null @@ -1,544 +0,0 @@ -package rpc_test - -import ( - "encoding/json" - "fmt" - "net" - "strconv" - "testing" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/rpc" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type MockRPCServer struct { - listener net.Listener - responses map[string]string -} - -func NewMockRPCServer() (*MockRPCServer, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, fmt.Errorf("failed to create listener: %v", err) - } - - server := &MockRPCServer{ - listener: listener, - responses: make(map[string]string), - } - - server.setupMockResponses() - - go server.serve() - return server, nil -} - -func (s *MockRPCServer) setupMockResponses() { - s.responses["version"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277143, - "Code": 22, - "Msg": "CGMiner versions", - "Description": "cgminer 1.0.0" - } - ], - "VERSION": [ - { - "BMMiner": "1.0.0", - "API": "3.1", - "Miner": "uart_trans.1.3", - "CompileTime": "Thu Jul 11 16:38:25 CST 2024", - "Type": "Antminer S21" - } - ], - "id": 1 - } - ` - - s.responses["summary"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277145, - "Code": 11, - "Msg": "Summary", - "Description": "cgminer 1.0.0" - } - ], - "SUMMARY": [ - { - "Elapsed": 59129, - "GHS 5s": 203007.01, - "GHS av": 203675.66, - "GHS 30m": 203915.73, - "Found Blocks": 0, - "Getwork": 6033, - "Accepted": 11187, - "Rejected": 9, - "Hardware Errors": 61, - "Utility": 11.35, - "Discarded": 5554970, - "Stale": 0, - "Get Failures": 0, - "Local Work": 5560881, - "Remote Failures": 0, - "Network Blocks": 102, - "Total MH": 1.2009939119E+13, - "Work Utility": 2829363.48, - "Difficulty Accepted": 2785931264.0, - "Difficulty Rejected": 2359296.0, - "Difficulty Stale": 0.0, - "Best Share": 5254813688, - "Device Hardware%": 0.0, - "Device Rejected%": 0.0, - "Pool Rejected%": 0.0, - "Pool Stale%": 0.0, - "Last getwork": 1750277145 - } - ], - "id": 1 - } - ` - - s.responses["pools"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277146, - "Code": 7, - "Msg": "3 Pool(s)", - "Description": "cgminer 1.0.0" - } - ], - "POOLS": [ - { - "POOL": 0, - "URL": "stratum+tcp://stratum.braiins.com:3333", - "Status": "Alive", - "Priority": 0, - "Quota": 1, - "Long Poll": "N", - "Getworks": 6033, - "Accepted": 11187, - "Rejected": 9, - "Discarded": 5555071, - "Stale": 0, - "Get Failures": 0, - "Remote Failures": 0, - "User": "proto_mining_sw_test_s21-0a5", - "Last Share Time": "0:00:12", - "Diff": "262K", - "Diff1 Shares": 0, - "Proxy Type": "", - "Proxy": "", - "Difficulty Accepted": 2785931264.0, - "Difficulty Rejected": 2359296.0, - "Difficulty Stale": 0.0, - "Last Share Difficulty": 262144.0, - "Has Stratum": true, - "Stratum Active": true, - "Stratum URL": "stratum.braiins.com", - "Has GBT": false, - "Best Share": 5254813688.0, - "Pool Rejected%": 0.0, - "Pool Stale%%": 0.0 - }, - { - "POOL": 1, - "URL": "", - "Status": "Deed", - "Priority": 1, - "Quota": 1, - "Long Poll": "N", - "Getworks": 0, - "Accepted": 0, - "Rejected": 0, - "Discarded": 0, - "Stale": 0, - "Get Failures": 0, - "Remote Failures": 0, - "User": "", - "Last Share Time": "0", - "Diff": "", - "Diff1 Shares": 0, - "Proxy Type": "", - "Proxy": "", - "Difficulty Accepted": 0.0, - "Difficulty Rejected": 0.0, - "Difficulty Stale": 0.0, - "Last Share Difficulty": 0.0, - "Has Stratum": false, - "Stratum Active": false, - "Stratum URL": "", - "Has GBT": false, - "Best Share": 0.0, - "Pool Rejected%": 0.0, - "Pool Stale%%": 0.0 - }, - { - "POOL": 2, - "URL": "", - "Status": "Deed", - "Priority": 2, - "Quota": 1, - "Long Poll": "N", - "Getworks": 0, - "Accepted": 0, - "Rejected": 0, - "Discarded": 0, - "Stale": 0, - "Get Failures": 0, - "Remote Failures": 0, - "User": "", - "Last Share Time": "0", - "Diff": "", - "Diff1 Shares": 0, - "Proxy Type": "", - "Proxy": "", - "Difficulty Accepted": 0.0, - "Difficulty Rejected": 0.0, - "Difficulty Stale": 0.0, - "Last Share Difficulty": 0.0, - "Has Stratum": false, - "Stratum Active": false, - "Stratum URL": "", - "Has GBT": false, - "Best Share": 0.0, - "Pool Rejected%": 0.0, - "Pool Stale%%": 0.0 - } - ], - "id": 1 - } - ` - - s.responses["devs"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277147, - "Code": 9, - "Msg": "1 ASC(s)", - "Description": "cgminer 1.0.0" - } - ], - "DEVS": [ - { - "ASC": 0, - "Name": "BTM_SOC", - "ID": 0, - "Enabled": "Y", - "Status": "Alive", - "Temperature": 65.0, - "MHS av": 203676.57, - "MHS 5s": 202266.83, - "Accepted": 11188, - "Rejected": 9, - "Hardware Errors": 0, - "Utility": 0.0, - "Last Share Pool": 0, - "Last Share Time": 1750277146, - "Total MH": 0.0, - "Diff1 Work": 0, - "Difficulty Accepted": 2786193408, - "Difficulty Rejected": 2359296, - "Last Share Difficulty": 1750277146, - "Last Valid Work": 1750277146, - "Device Hardware%": 0.0, - "Device Rejected%": 0.0, - "Device Elapsed": 59131 - } - ], - "id": 1 - } - ` - - s.responses["stats"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277150, - "Code": 70, - "Msg": "CGMiner stats", - "Description": "cgminer 1.0.0" - } - ], - "STATS": [ - {"BMMiner": "1.0.0", "Miner": "49.0.1.3", "CompileTime": "Thu Jul 11 16:38:25 CST 2024", "Type": "Antminer S21"}, - {"STATS": 0, "ID": "BTM_SOC0", "Elapsed": 59129, "GHS 5s": 203007.01, "GHS av": 203675.66, "chain_power": "3250 W", "fan_num": 4, "fan1": 6000, "fan2": 5880, "fan3": 5040, "fan4": 5040} - ], - "id": 1 - } - ` - - s.responses["config"] = ` - { - "STATUS": [ - { - "STATUS": "S", - "When": 1750277148, - "Code": 33, - "Msg": "CGMiner config", - "Description": "cgminer 1.0.0" - } - ], - "CONFIG": [ - { - "ASC Count": 3, - "PGA Count": 0, - "Pool Count": 3, - "Strategy": "Failover", - "Log Interval": 5, - "Device Code": "BTM_SOC", - "OS": "Linux", - "Failover-Only": true, - "ScanTime": 60, - "Queue": 1, - "Expiry": 120 - } - ], - "id": 1 - } - ` -} - -func (s *MockRPCServer) serve() { - for { - conn, err := s.listener.Accept() - if err != nil { - return - } - go s.handleConnection(conn) - } -} - -func (s *MockRPCServer) handleConnection(conn net.Conn) { - defer conn.Close() - - buffer := make([]byte, 1024) - n, err := conn.Read(buffer) - if err != nil { - return - } - - var request rpc.RPCRequest - if err := json.Unmarshal(buffer[:n], &request); err != nil { - return - } - - response, exists := s.responses[request.Command] - if !exists { - response = ` - { - "STATUS": [ - { - "STATUS": "E", - "When": 1750277148, - "Code": 14, - "Msg": "Invalid command", - "Description": "cgminer 1.0.0" - } - ], - "id": 1 - } - ` - } - - _, err = conn.Write([]byte(response)) - if err != nil { - return - } -} - -func (s *MockRPCServer) GetAddress() string { - return s.listener.Addr().String() -} - -func (s *MockRPCServer) Close() { - s.listener.Close() -} - -func TestRPCCommands(t *testing.T) { - server, err := NewMockRPCServer() - require.NoError(t, err, "Failed to create mock server") - defer server.Close() - - host, portStr, err := net.SplitHostPort(server.GetAddress()) - require.NoError(t, err, "Failed to split host port") - portInt, err := strconv.Atoi(portStr) - require.NoError(t, err, "Failed to convert port to int") - - rpcClient := rpc.NewService() - connInfo := &networking.ConnectionInfo{ - IPAddress: networking.IPAddress(host), - Port: networking.Port(portInt), //nolint:gosec // This is a test - Protocol: networking.ProtocolTCP, - } - - t.Run("GetVersion", func(t *testing.T) { - response, err := rpcClient.GetVersion(t.Context(), connInfo) - require.NoError(t, err, "GetVersion should not return error") - assert.NotZero(t, response, "Response should not be nil") - assert.Len(t, response.Version, 1, "Should have one version entry") - - version := response.Version[0] - assert.Equal(t, "1.0.0", version.BMMiner, "BMMiner version should match") - assert.Equal(t, "3.1", version.API, "API version should match") - assert.Equal(t, "uart_trans.1.3", version.Miner, "Miner model should match") - assert.Equal(t, "Thu Jul 11 16:38:25 CST 2024", version.CompileTime, "Compile time should match") - assert.Equal(t, "Antminer S21", version.Type, "Miner type should match") - - // Check status - assert.Len(t, response.Status, 1, "Should have one status entry") - assert.Equal(t, "S", response.Status[0].Status, "Status should be Success") - assert.Equal(t, "CGMiner versions", response.Status[0].Msg, "Message should match") - }) - - t.Run("GetSummary", func(t *testing.T) { - response, err := rpcClient.GetSummary(t.Context(), connInfo) - require.NoError(t, err, "GetSummary should not return error") - assert.NotZero(t, response, "Response should not be nil") - assert.Len(t, response.Summary, 1, "Should have one summary entry") - - summary := response.Summary[0] - assert.Equal(t, int64(59129), summary.Elapsed, "Elapsed time should match") - assert.InDelta(t, 203007.01, summary.GHS5s, 0.01, "5s hash rate should match") - assert.InDelta(t, 203675.66, summary.GHSAv, 0.01, "Average hash rate should match") - assert.Equal(t, int64(11187), summary.Accepted, "Accepted shares should match") - assert.Equal(t, int64(9), summary.Rejected, "Rejected shares should match") - assert.Equal(t, int64(61), summary.HardwareErrors, "Hardware errors should match") - assert.InDelta(t, 11.35, summary.Utility, 0.01, "Utility should match") - assert.InDelta(t, 2829363.48, summary.WorkUtility, 0.01, "Work utility should match") - }) - - t.Run("GetPools", func(t *testing.T) { - response, err := rpcClient.GetPools(t.Context(), connInfo) - require.NoError(t, err, "GetPools should not return error") - assert.NotZero(t, response, "Response should not be nil") - assert.Len(t, response.Pools, 3, "Should have 3 pool entries") - - // Test active pool - activePool := response.Pools[0] - assert.Equal(t, 0, activePool.Pool, "Pool number should be 0") - assert.Equal(t, "stratum+tcp://stratum.braiins.com:3333", activePool.URL, "Pool URL should match") - assert.Equal(t, "proto_mining_sw_test_s21-0a5", activePool.User, "Pool user should match") - assert.Equal(t, "Alive", activePool.Status, "Pool status should be Alive") - assert.Equal(t, 0, activePool.Priority, "Pool priority should be 0") - assert.Equal(t, int64(11187), activePool.Accepted, "Accepted shares should match") - assert.Equal(t, int64(9), activePool.Rejected, "Rejected shares should match") - - // Test inactive pools - for i := 1; i < 3; i++ { - pool := response.Pools[i] - assert.Equal(t, i, pool.Pool, "Pool number should match index") - assert.Equal(t, "", pool.URL, "Inactive pool URL should be empty") - assert.Equal(t, "Deed", pool.Status, "Inactive pool status should be Dead") // Note: typo in actual response - } - }) - - t.Run("GetDevs", func(t *testing.T) { - response, err := rpcClient.GetDevs(t.Context(), connInfo) - require.NoError(t, err, "GetDevs should not return error") - assert.NotZero(t, response, "Response should not be nil") - assert.Len(t, response.Devs, 1, "Should have 1 device entry") - - // Test device - dev := response.Devs[0] - assert.Equal(t, 0, dev.ASC, "ASC number should be 0") - assert.Equal(t, "BTM_SOC", dev.Name, "Device name should match") - assert.Equal(t, 0, dev.ID, "Device ID should be 0") - assert.Equal(t, "Y", dev.Enabled, "Device should be enabled") - assert.Equal(t, "Alive", dev.Status, "Device status should be Alive") - assert.InDelta(t, 65.0, dev.Temperature, 0.01, "Temperature should match") - assert.InDelta(t, 203676.57, dev.MHSAv, 0.01, "Average MHS should match") - assert.Equal(t, int64(11188), dev.Accepted, "Accepted shares should match") - assert.Equal(t, int64(9), dev.Rejected, "Rejected shares should match") - assert.Equal(t, int64(0), dev.HardwareErrors, "Hardware errors should be 0") - }) - - t.Run("GetStats", func(t *testing.T) { - response, err := rpcClient.GetStats(t.Context(), connInfo) - require.NoError(t, err, "GetStats should not return error") - assert.NotZero(t, response, "Response should not be nil") - - // Check status - assert.Len(t, response.Status, 1, "Should have one status entry") - assert.Equal(t, "S", response.Status[0].Status, "Status should be Success") - - // STATS array should have 2 entries (firmware info + mining stats) - assert.Len(t, response.Stats, 2, "Should have two STATS entries") - - // Parse the second entry to verify chain_power is present - var statsData map[string]json.RawMessage - err = json.Unmarshal(response.Stats[1], &statsData) - require.NoError(t, err, "Should be able to parse STATS[1]") - - _, hasChainPower := statsData["chain_power"] - assert.True(t, hasChainPower, "STATS[1] should contain chain_power") - }) - - t.Run("GetConfig", func(t *testing.T) { - response, err := rpcClient.GetConfig(t.Context(), connInfo) - require.NoError(t, err, "GetConfig should not return error") - assert.NotZero(t, response, "Response should not be nil") - assert.Len(t, response.Config, 1, "Should have one config entry") - - config := response.Config[0] - assert.Equal(t, 3, config.ASCCount, "ASC count should be 3") - assert.Equal(t, 0, config.PGACount, "PGA count should be 0") - assert.Equal(t, 3, config.PoolCount, "Pool count should be 3") - assert.Equal(t, "Failover", config.Strategy, "Strategy should be Failover") - assert.Equal(t, 5, config.LogInterval, "Log interval should be 5") - assert.Equal(t, "BTM_SOC", config.DeviceCode, "Device code should match") - assert.Equal(t, "Linux", config.OS, "OS should be Linux") - }) -} - -func TestRPCErrorHandling(t *testing.T) { - rpcClient := rpc.NewService() - connInfo := &networking.ConnectionInfo{ - IPAddress: "127.0.0.1", - Port: networking.Port(9999), // Non-existent port - } - - t.Run("ConnectionFailure", func(t *testing.T) { - _, err := rpcClient.GetVersion(t.Context(), connInfo) - require.Error(t, err, "Should return error for connection failure") - assert.Contains(t, err.Error(), "failed to connect", "Error should mention connection failure") - }) -} - -// Test invalid command -func TestRPCInvalidCommand(t *testing.T) { - server, err := NewMockRPCServer() - require.NoError(t, err, "Failed to create mock server") - defer server.Close() - - // Test with raw connection to send invalid command - conn, err := net.Dial("tcp", server.GetAddress()) - require.NoError(t, err, "Failed to connect to mock server") - defer conn.Close() - - // Send invalid command - invalidRequest := `{"command": "invalid_command"}` - n, err := conn.Write([]byte(invalidRequest)) - require.NoError(t, err, "Failed to write invalid request") - assert.Positive(t, n, "Should write some bytes") - - // Read response - buffer := make([]byte, 1024) - n, err = conn.Read(buffer) - require.NoError(t, err, "Failed to read response") - - response := string(buffer[:n]) - assert.Contains(t, response, "Invalid command", "Response should contain error message") -} diff --git a/plugin/antminer/pkg/antminer/rpc/types.go b/plugin/antminer/pkg/antminer/rpc/types.go deleted file mode 100644 index 126feb7e76..0000000000 --- a/plugin/antminer/pkg/antminer/rpc/types.go +++ /dev/null @@ -1,179 +0,0 @@ -package rpc - -import "encoding/json" - -type RPCRequest struct { - Command string `json:"command"` - Parameter string `json:"parameter,omitempty"` -} - -type RPCResponse struct { - Status []StatusInfo `json:"STATUS,omitempty"` - ID int `json:"id,omitempty"` -} - -type StatusInfo struct { - Status string `json:"STATUS"` - When int64 `json:"When"` - Code int `json:"Code"` - Msg string `json:"Msg"` - Description string `json:"Description"` -} - -type VersionResponse struct { - Status []StatusInfo `json:"STATUS"` - Version []VersionInfo `json:"VERSION"` - ID int `json:"id"` -} - -type VersionInfo struct { - BMMiner string `json:"BMMiner"` - LUXminer string `json:"LUXminer"` - API string `json:"API"` - Miner string `json:"Miner"` - CompileTime string `json:"CompileTime"` - Type string `json:"Type"` -} - -type SummaryResponse struct { - Status []StatusInfo `json:"STATUS"` - Summary []SummaryInfo `json:"SUMMARY"` - ID int `json:"id"` -} - -type SummaryInfo struct { - // All SummaryInfo fields: - Elapsed int64 `json:"Elapsed"` - GHS5s float64 `json:"GHS 5s"` - GHSAv float64 `json:"GHS av"` - GHS30m float64 `json:"GHS 30m"` - FoundBlocks int64 `json:"Found Blocks"` - Getwork int64 `json:"Getwork"` - Accepted int64 `json:"Accepted"` - Rejected int64 `json:"Rejected"` - HardwareErrors int64 `json:"Hardware Errors"` - Utility float64 `json:"Utility"` - Discarded int64 `json:"Discarded"` - Stale int64 `json:"Stale"` - GetFailures int64 `json:"Get Failures"` - LocalWork int64 `json:"Local Work"` - RemoteFailures int64 `json:"Remote Failures"` - NetworkBlocks int64 `json:"Network Blocks"` - TotalMH float64 `json:"Total MH"` - WorkUtility float64 `json:"Work Utility"` - DifficultyAccepted float64 `json:"Difficulty Accepted"` - DifficultyRejected float64 `json:"Difficulty Rejected"` - DifficultyStale float64 `json:"Difficulty Stale"` - BestShare int64 `json:"Best Share"` - DeviceHardwarePercent float64 `json:"Device Hardware%"` - DeviceRejectedPercent float64 `json:"Device Rejected%"` - PoolRejectedPercent float64 `json:"Pool Rejected%"` - PoolStalePercent float64 `json:"Pool Stale%"` - LastGetwork int64 `json:"Last getwork"` -} - -type PoolsResponse struct { - Status []StatusInfo `json:"STATUS"` - Pools []PoolInfo `json:"POOLS"` - ID int `json:"id"` -} - -type PoolInfo struct { - Pool int `json:"POOL"` - URL string `json:"URL"` - Status string `json:"Status"` - Priority int `json:"Priority"` - Quota int `json:"Quota"` - LongPoll string `json:"Long Poll"` - Getworks int64 `json:"Getworks"` - Accepted int64 `json:"Accepted"` - Rejected int64 `json:"Rejected"` - Discarded int64 `json:"Discarded"` - Stale int64 `json:"Stale"` - GetFailures int64 `json:"Get Failures"` - RemoteFailures int64 `json:"Remote Failures"` - User string `json:"User"` - LastShareTime string `json:"Last Share Time"` - Diff string `json:"Diff"` - Diff1Shares int64 `json:"Diff1 Shares"` - ProxyType string `json:"Proxy Type"` - Proxy string `json:"Proxy"` - DifficultyAccepted float64 `json:"Difficulty Accepted"` - DifficultyRejected float64 `json:"Difficulty Rejected"` - DifficultyStale float64 `json:"Difficulty Stale"` - LastShareDifficulty float64 `json:"Last Share Difficulty"` - HasStratum bool `json:"Has Stratum"` - StratumActive bool `json:"Stratum Active"` - StratumURL string `json:"Stratum URL"` - HasGBT bool `json:"Has GBT"` - BestShare float64 `json:"Best Share"` - PoolRejectedPercent float64 `json:"Pool Rejected%"` - PoolStalePercent float64 `json:"Pool Stale%"` -} - -type DevsResponse struct { - Status []StatusInfo `json:"STATUS"` - Devs []DevInfo `json:"DEVS"` - ID int `json:"id"` -} - -type DevInfo struct { - ASC int `json:"ASC"` - Name string `json:"Name"` - ID int `json:"ID"` - Enabled string `json:"Enabled"` - Status string `json:"Status"` - Temperature float64 `json:"Temperature"` // Correct spelling - Tenperature float64 `json:"Tenperature"` // TYPO REQUIRED: Antminer firmware actually uses this misspelling - MHSAv float64 `json:"MHS av"` - MHS5s float64 `json:"MHS 5s"` - Accepted int64 `json:"Accepted"` - Rejected int64 `json:"Rejected"` - HardwareErrors int64 `json:"Hardware Errors"` - Utility float64 `json:"Utility"` - LastSharePool int `json:"Last Share Pool"` - LastShareTime int64 `json:"Last Share Time"` - TotalMH float64 `json:"Total MH"` - Diff1Work int64 `json:"Diff1 Work"` - DifficultyAccepted float64 `json:"Difficulty Accepted"` - DifficultyRejected float64 `json:"Difficulty Rejected"` - LastShareDifficulty float64 `json:"Last Share Difficulty"` - LastValidWork int64 `json:"Last Valid Work"` - DeviceHardwarePercent float64 `json:"Device Hardware%"` - DeviceRejectedPercent float64 `json:"Device Rejected%"` - DeviceElapsed int64 `json:"Device Elapsed"` -} - -// GetTemperature returns the temperature value of the device. -// Checks both correct spelling (Temperature) and the typo (Tenperature) used by Antminer firmware. -func (d *DevInfo) GetTemperature() float64 { - if d.Temperature != 0 { - return d.Temperature - } - return d.Tenperature -} - -type ConfigResponse struct { - Status []StatusInfo `json:"STATUS"` - Config []ConfigInfo `json:"CONFIG"` - ID int `json:"id"` -} - -type ConfigInfo struct { - ASCCount int `json:"ASC Count"` - PGACount int `json:"PGA Count"` - PoolCount int `json:"Pool Count"` - Strategy string `json:"Strategy"` - LogInterval int `json:"Log Interval"` - DeviceCode string `json:"Device Code"` - OS string `json:"OS"` -} - -// StatsResponse represents the response from the "stats" RPC command. -// The STATS array contains mixed types: the first element is firmware metadata, -// and the second element contains the actual mining stats (including power). -type StatsResponse struct { - Status []StatusInfo `json:"STATUS"` - Stats []json.RawMessage `json:"STATS"` - ID int `json:"id"` -} diff --git a/plugin/antminer/pkg/antminer/web/config.go b/plugin/antminer/pkg/antminer/web/config.go deleted file mode 100644 index c8b2f1f61c..0000000000 --- a/plugin/antminer/pkg/antminer/web/config.go +++ /dev/null @@ -1,18 +0,0 @@ -package web - -import ( - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" - "github.com/block/proto-fleet/server/sdk/v1" -) - -type AntminerConnectionInfo struct { - networking.ConnectionInfo - Creds sdk.UsernamePassword -} - -func NewAntminerConnectionInfo(connectionInfo networking.ConnectionInfo, credential sdk.UsernamePassword) *AntminerConnectionInfo { - return &AntminerConnectionInfo{ - ConnectionInfo: connectionInfo, - Creds: credential, - } -} diff --git a/plugin/antminer/pkg/antminer/web/digest.go b/plugin/antminer/pkg/antminer/web/digest.go deleted file mode 100644 index bd53a6d60b..0000000000 --- a/plugin/antminer/pkg/antminer/web/digest.go +++ /dev/null @@ -1,24 +0,0 @@ -package web - -import "github.com/block/proto-fleet/server/sdk/v1" - -type DigestAuth struct { - creds sdk.UsernamePassword - Realm string - Nonce string - URI string - Algorithm string - Response string - Opaque string - QOP string - NC string - CNonce string -} - -type DigestChallenge struct { - Realm string - Nonce string - Opaque string - Algorithm string - QOP string -} diff --git a/plugin/antminer/pkg/antminer/web/mocks/mock_web_api_client.go b/plugin/antminer/pkg/antminer/web/mocks/mock_web_api_client.go deleted file mode 100644 index 95ed9b699b..0000000000 --- a/plugin/antminer/pkg/antminer/web/mocks/mock_web_api_client.go +++ /dev/null @@ -1,217 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: service.go -// -// Generated by this command: -// -// mockgen -source=service.go -destination=mocks/mock_web_api_client.go -package=mocks WebAPIClient -// - -// Package mocks is a generated GoMock package. -package mocks - -import ( - context "context" - reflect "reflect" - - web "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - sdk "github.com/block/proto-fleet/server/sdk/v1" - gomock "go.uber.org/mock/gomock" -) - -// MockWebAPIClient is a mock of WebAPIClient interface. -type MockWebAPIClient struct { - ctrl *gomock.Controller - recorder *MockWebAPIClientMockRecorder - isgomock struct{} -} - -// MockWebAPIClientMockRecorder is the mock recorder for MockWebAPIClient. -type MockWebAPIClientMockRecorder struct { - mock *MockWebAPIClient -} - -// NewMockWebAPIClient creates a new mock instance. -func NewMockWebAPIClient(ctrl *gomock.Controller) *MockWebAPIClient { - mock := &MockWebAPIClient{ctrl: ctrl} - mock.recorder = &MockWebAPIClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockWebAPIClient) EXPECT() *MockWebAPIClientMockRecorder { - return m.recorder -} - -// ChangePassword mocks base method. -func (m *MockWebAPIClient) ChangePassword(ctx context.Context, connInfo *web.AntminerConnectionInfo, currentPassword, newPassword string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ChangePassword", ctx, connInfo, currentPassword, newPassword) - ret0, _ := ret[0].(error) - return ret0 -} - -// ChangePassword indicates an expected call of ChangePassword. -func (mr *MockWebAPIClientMockRecorder) ChangePassword(ctx, connInfo, currentPassword, newPassword any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangePassword", reflect.TypeOf((*MockWebAPIClient)(nil).ChangePassword), ctx, connInfo, currentPassword, newPassword) -} - -// GetKernelLog mocks base method. -func (m *MockWebAPIClient) GetKernelLog(ctx context.Context, connInfo *web.AntminerConnectionInfo) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetKernelLog", ctx, connInfo) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetKernelLog indicates an expected call of GetKernelLog. -func (mr *MockWebAPIClientMockRecorder) GetKernelLog(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKernelLog", reflect.TypeOf((*MockWebAPIClient)(nil).GetKernelLog), ctx, connInfo) -} - -// GetMinerConfig mocks base method. -func (m *MockWebAPIClient) GetMinerConfig(ctx context.Context, connInfo *web.AntminerConnectionInfo) (*web.MinerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMinerConfig", ctx, connInfo) - ret0, _ := ret[0].(*web.MinerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMinerConfig indicates an expected call of GetMinerConfig. -func (mr *MockWebAPIClientMockRecorder) GetMinerConfig(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinerConfig", reflect.TypeOf((*MockWebAPIClient)(nil).GetMinerConfig), ctx, connInfo) -} - -// GetMinerSummary mocks base method. -func (m *MockWebAPIClient) GetMinerSummary(ctx context.Context, connInfo *web.AntminerConnectionInfo) (*web.MinerSummary, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMinerSummary", ctx, connInfo) - ret0, _ := ret[0].(*web.MinerSummary) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMinerSummary indicates an expected call of GetMinerSummary. -func (mr *MockWebAPIClientMockRecorder) GetMinerSummary(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinerSummary", reflect.TypeOf((*MockWebAPIClient)(nil).GetMinerSummary), ctx, connInfo) -} - -// GetNetworkInfo mocks base method. -func (m *MockWebAPIClient) GetNetworkInfo(ctx context.Context, connInfo *web.AntminerConnectionInfo) (*web.NetworkInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNetworkInfo", ctx, connInfo) - ret0, _ := ret[0].(*web.NetworkInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetNetworkInfo indicates an expected call of GetNetworkInfo. -func (mr *MockWebAPIClientMockRecorder) GetNetworkInfo(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkInfo", reflect.TypeOf((*MockWebAPIClient)(nil).GetNetworkInfo), ctx, connInfo) -} - -// GetStatsInfo mocks base method. -func (m *MockWebAPIClient) GetStatsInfo(ctx context.Context, connInfo *web.AntminerConnectionInfo) (*web.StatsInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetStatsInfo", ctx, connInfo) - ret0, _ := ret[0].(*web.StatsInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetStatsInfo indicates an expected call of GetStatsInfo. -func (mr *MockWebAPIClientMockRecorder) GetStatsInfo(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatsInfo", reflect.TypeOf((*MockWebAPIClient)(nil).GetStatsInfo), ctx, connInfo) -} - -// GetSystemInfo mocks base method. -func (m *MockWebAPIClient) GetSystemInfo(ctx context.Context, connInfo *web.AntminerConnectionInfo) (*web.SystemInfo, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSystemInfo", ctx, connInfo) - ret0, _ := ret[0].(*web.SystemInfo) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetSystemInfo indicates an expected call of GetSystemInfo. -func (mr *MockWebAPIClientMockRecorder) GetSystemInfo(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemInfo", reflect.TypeOf((*MockWebAPIClient)(nil).GetSystemInfo), ctx, connInfo) -} - -// Reboot mocks base method. -func (m *MockWebAPIClient) Reboot(ctx context.Context, connInfo *web.AntminerConnectionInfo) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Reboot", ctx, connInfo) - ret0, _ := ret[0].(error) - return ret0 -} - -// Reboot indicates an expected call of Reboot. -func (mr *MockWebAPIClientMockRecorder) Reboot(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reboot", reflect.TypeOf((*MockWebAPIClient)(nil).Reboot), ctx, connInfo) -} - -// SetMinerConfig mocks base method. -func (m *MockWebAPIClient) SetMinerConfig(ctx context.Context, connInfo *web.AntminerConnectionInfo, config *web.MinerConfig) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetMinerConfig", ctx, connInfo, config) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetMinerConfig indicates an expected call of SetMinerConfig. -func (mr *MockWebAPIClientMockRecorder) SetMinerConfig(ctx, connInfo, config any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinerConfig", reflect.TypeOf((*MockWebAPIClient)(nil).SetMinerConfig), ctx, connInfo, config) -} - -// StartBlink mocks base method. -func (m *MockWebAPIClient) StartBlink(ctx context.Context, connInfo *web.AntminerConnectionInfo) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StartBlink", ctx, connInfo) - ret0, _ := ret[0].(error) - return ret0 -} - -// StartBlink indicates an expected call of StartBlink. -func (mr *MockWebAPIClientMockRecorder) StartBlink(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartBlink", reflect.TypeOf((*MockWebAPIClient)(nil).StartBlink), ctx, connInfo) -} - -// StopBlink mocks base method. -func (m *MockWebAPIClient) StopBlink(ctx context.Context, connInfo *web.AntminerConnectionInfo) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StopBlink", ctx, connInfo) - ret0, _ := ret[0].(error) - return ret0 -} - -// StopBlink indicates an expected call of StopBlink. -func (mr *MockWebAPIClientMockRecorder) StopBlink(ctx, connInfo any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopBlink", reflect.TypeOf((*MockWebAPIClient)(nil).StopBlink), ctx, connInfo) -} - -// UploadFirmware mocks base method. -func (m *MockWebAPIClient) UploadFirmware(ctx context.Context, connInfo *web.AntminerConnectionInfo, firmware sdk.FirmwareFile) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UploadFirmware", ctx, connInfo, firmware) - ret0, _ := ret[0].(error) - return ret0 -} - -// UploadFirmware indicates an expected call of UploadFirmware. -func (mr *MockWebAPIClientMockRecorder) UploadFirmware(ctx, connInfo, firmware any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFirmware", reflect.TypeOf((*MockWebAPIClient)(nil).UploadFirmware), ctx, connInfo, firmware) -} diff --git a/plugin/antminer/pkg/antminer/web/service.go b/plugin/antminer/pkg/antminer/web/service.go deleted file mode 100644 index b9ce5f1382..0000000000 --- a/plugin/antminer/pkg/antminer/web/service.go +++ /dev/null @@ -1,748 +0,0 @@ -package web - -import ( - "bytes" - "context" - "crypto/md5" // #nosec G501 - Required for digest authentication with Antminer devices - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "regexp" - "strings" - "time" - - "github.com/block/proto-fleet/server/sdk/v1" -) - -const ( - scheme = "http" - endpointSystemInfo = "/cgi-bin/get_system_info.cgi" - endpointMinerSummary = "/cgi-bin/summary.cgi" - endpointMinerConfig = "/cgi-bin/get_miner_conf.cgi" - endpointNetworkInfo = "/cgi-bin/get_network_info.cgi" - endpointSetConfig = "/cgi-bin/set_miner_conf.cgi" - endpointReboot = "/cgi-bin/reboot.cgi" - endpointBlink = "/cgi-bin/blink.cgi" - endpointStats = "/cgi-bin/stats.cgi" - endpointKernelLog = "/cgi-bin/get_kernel_log.cgi" - endpointLegacyLog = "/cgi-bin/log.cgi" - //#nosec G101 -- API endpoint path, not credentials - endpointPassword = "/cgi-bin/passwd.cgi" - endpointUpgrade = "/cgi-bin/upgrade.cgi" -) - -// errEndpointNotFound is returned when a CGI endpoint returns HTTP 404. -var errEndpointNotFound = errors.New("endpoint not found") - -// BitmainWorkMode represents the operating mode of an Antminer device -type BitmainWorkMode string - -// Bitmain work mode constants -const ( - BitmainWorkModeStart BitmainWorkMode = "0" // Normal operation - BitmainWorkModeSleep BitmainWorkMode = "1" // Sleep mode - BitmainWorkModeLowPower BitmainWorkMode = "3" // Low power mode -) - -//go:generate go run go.uber.org/mock/mockgen -source=service.go -destination=mocks/mock_web_api_client.go -package=mocks WebAPIClient -//nolint:interfacebloat // Antminer web API has many endpoints -type WebAPIClient interface { - GetSystemInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*SystemInfo, error) - GetMinerSummary(ctx context.Context, connInfo *AntminerConnectionInfo) (*MinerSummary, error) - GetMinerConfig(ctx context.Context, connInfo *AntminerConnectionInfo) (*MinerConfig, error) - GetNetworkInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*NetworkInfo, error) - GetStatsInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*StatsInfo, error) - SetMinerConfig(ctx context.Context, connInfo *AntminerConnectionInfo, config *MinerConfig) error - Reboot(ctx context.Context, connInfo *AntminerConnectionInfo) error - StartBlink(ctx context.Context, connInfo *AntminerConnectionInfo) error - StopBlink(ctx context.Context, connInfo *AntminerConnectionInfo) error - GetKernelLog(ctx context.Context, connInfo *AntminerConnectionInfo) (string, error) - ChangePassword(ctx context.Context, connInfo *AntminerConnectionInfo, currentPassword, newPassword string) error - UploadFirmware(ctx context.Context, connInfo *AntminerConnectionInfo, firmware sdk.FirmwareFile) error -} - -var _ WebAPIClient = &Service{} - -const ( - // DefaultPort is the default port for the Antminer HTTP API - DefaultPort = "80" - - // cnonceBufferSize is the size of the buffer for generating client nonce in digest auth - cnonceBufferSize = 16 - - // Firmware upload can transfer hundreds of megabytes over slow links. - firmwareUploadTimeout = 30 * time.Minute -) - -type ServiceOptions func(*Service) - -type Service struct { - httpClient *http.Client -} - -func NewService() *Service { - return &Service{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -type SystemInfo struct { - MinerType string `json:"minertype"` - NetType string `json:"nettype"` - NetDevice string `json:"netdevice"` - MacAddr string `json:"macaddr"` - Hostname string `json:"hostname"` - IPAddress string `json:"ipaddress"` - NetMask string `json:"netmask"` - Gateway string `json:"gateway"` - DNSServers string `json:"dnsservers"` - SystemMode string `json:"system_mode"` - SystemKernelVersion string `json:"system_kernel_version"` - SystemFilesystemVersion string `json:"system_filesystem_version"` - FirmwareType string `json:"firmware_type"` - SerialNumber string `json:"serinum"` -} - -type MinerSummary struct { - Status []struct { - Status string `json:"STATUS"` - When int64 `json:"When"` - Code int `json:"Code"` - Msg string `json:"Msg"` - Description string `json:"Description"` - } `json:"STATUS"` - Info struct { - MinerVersion string `json:"miner_version"` - CompileTime string `json:"CompileTime"` - Type string `json:"type"` - } `json:"INFO"` - Summary []struct { - Elapsed int `json:"elapsed"` - Rate5s float64 `json:"rate_5s"` - Rate30m float64 `json:"rate_30m"` - RateAvg float64 `json:"rate_avg"` - RateIdeal float64 `json:"rate_ideal"` - RateUnit string `json:"rate_unit"` - HwAll int `json:"hw_all"` - BestShare int64 `json:"bestshare"` - Status []struct { - Type string `json:"type"` - Status string `json:"status"` - Code int `json:"code"` - Msg string `json:"msg"` - } `json:"status"` - } `json:"SUMMARY"` -} - -type Pool struct { - URL string `json:"url"` - Username string `json:"user"` - Password string `json:"pass"` -} - -type MinerConfig struct { - Pools []Pool `json:"pools"` - APIListen bool `json:"api-listen"` - APINetwork bool `json:"api-network"` - APIGroups string `json:"api-groups"` - APIAllow string `json:"api-allow"` - BitmainFanCtrl bool `json:"bitmain-fan-ctrl"` - BitmainFanPWM string `json:"bitmain-fan-pwm"` - BitmainUseVil bool `json:"bitmain-use-vil"` - BitmainFreq string `json:"bitmain-freq"` - BitmainVoltage string `json:"bitmain-voltage"` - BitmainCCDelay string `json:"bitmain-ccdelay"` - BitmainPWTH string `json:"bitmain-pwth"` - // MinerMode is the legacy work-mode field used by older Antminer firmware. - // Newer firmware uses BitmainWorkMode instead. Both encode the same values - // ("0" = normal, "1" = sleep). The omitempty tags prevent sending an - // empty field to devices that don't use it. - MinerMode string `json:"miner-mode,omitempty"` - BitmainWorkMode BitmainWorkMode `json:"bitmain-work-mode,omitempty"` - BitmainHashratePercent string `json:"bitmain-hashrate-percent"` - BitmainFreqLevel string `json:"bitmain-freq-level"` -} - -type NetworkInfo struct { - NetType string `json:"nettype"` - NetDevice string `json:"netdevice"` - MacAddr string `json:"macaddr"` - IPAddress string `json:"ipaddress"` - NetMask string `json:"netmask"` - ConfNetType string `json:"conf_nettype"` - ConfHostname string `json:"conf_hostname"` - ConfIPAddress string `json:"conf_ipaddress"` - ConfNetMask string `json:"conf_netmask"` - ConfGateway string `json:"conf_gateway"` - ConfDNSServers string `json:"conf_dnsservers"` -} - -// StatsStatus contains status information from stats.cgi response -type StatsStatus struct { - Status string `json:"STATUS"` - When int64 `json:"when"` - Msg string `json:"Msg"` - APIVersion string `json:"api_version"` -} - -// StatsMinerInfo contains miner version and type information -type StatsMinerInfo struct { - MinerVersion string `json:"miner_version"` - CompileTime string `json:"CompileTime"` - Type string `json:"type"` -} - -// ChainStats represents per-chain telemetry data from stats.cgi -type ChainStats struct { - Index int `json:"index"` - FreqAvg int `json:"freq_avg"` - RateIdeal float64 `json:"rate_ideal"` - RateReal float64 `json:"rate_real"` - ASICNum int `json:"asic_num"` - TempPIC []float64 `json:"temp_pic"` - TempPCB []float64 `json:"temp_pcb"` - TempChip []float64 `json:"temp_chip"` - HW int `json:"hw"` - SN string `json:"sn"` - HWP float64 `json:"hwp"` -} - -type PSUStats struct { - Index int `json:"index"` - Status string `json:"status"` -} - -// StatsData contains aggregated miner statistics and per-chain metrics -type StatsData struct { - Elapsed int `json:"elapsed"` - Rate5s float64 `json:"rate_5s"` - Rate30m float64 `json:"rate_30m"` - RateAvg float64 `json:"rate_avg"` - RateIdeal float64 `json:"rate_ideal"` - RateUnit string `json:"rate_unit"` - ChainNum int `json:"chain_num"` - FanNum int `json:"fan_num"` - Fan []int `json:"fan"` - HWPTotal float64 `json:"hwp_total"` - PSU *PSUStats `json:"psu,omitempty"` - Chain []ChainStats `json:"chain"` -} - -// StatsInfo represents the complete response from stats.cgi endpoint -type StatsInfo struct { - STATUS StatsStatus `json:"STATUS"` - INFO StatsMinerInfo `json:"INFO"` - STATS []StatsData `json:"STATS"` -} - -type RequestOptions struct { - Method string - Endpoint string - Body interface{} - Result interface{} - ContentType string -} - -func (s *Service) buildURL(connInfo *AntminerConnectionInfo, endpoint string) string { - return connInfo.GetURL().JoinPath(endpoint).String() -} - -func (s *Service) request(ctx context.Context, connInfo *AntminerConnectionInfo, opts RequestOptions) error { - reqURL := s.buildURL(connInfo, opts.Endpoint) - - var bodyReader io.Reader - if opts.Body != nil { - bodyBuf := &bytes.Buffer{} - encoder := json.NewEncoder(bodyBuf) - if err := encoder.Encode(opts.Body); err != nil { - return fmt.Errorf("failed to encode request body: %w", err) - } - bodyReader = bodyBuf - } - - req, err := http.NewRequestWithContext(ctx, opts.Method, reqURL, bodyReader) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - if bodyReader != nil { - if opts.ContentType != "" { - req.Header.Set("Content-Type", opts.ContentType) - } else { - req.Header.Set("Content-Type", "application/json") - } - } - - if connInfo.Creds.Username != "" && connInfo.Creds.Password != "" { - if err := s.addDigestAuth(req, connInfo.Creds); err != nil { - return fmt.Errorf("failed to add digest auth: %w", err) - } - } - - resp, err := s.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized { - return sdk.NewErrorAuthenticationFailed(connInfo.GetURL().String()) - } - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - if opts.Result != nil { - decoder := json.NewDecoder(resp.Body) - if err := decoder.Decode(opts.Result); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - } - - return nil -} - -func (s *Service) requestRaw(ctx context.Context, connInfo *AntminerConnectionInfo, opts RequestOptions) (string, error) { - reqURL := s.buildURL(connInfo, opts.Endpoint) - - req, err := http.NewRequestWithContext(ctx, opts.Method, reqURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - if connInfo.Creds.Username != "" && connInfo.Creds.Password != "" { - if err := s.addDigestAuth(req, connInfo.Creds); err != nil { - return "", fmt.Errorf("failed to add digest auth: %w", err) - } - } - - resp, err := s.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized { - return "", sdk.NewErrorAuthenticationFailed(connInfo.GetURL().String()) - } - if resp.StatusCode == http.StatusNotFound { - return "", errEndpointNotFound - } - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - return string(body), nil -} - -func (s *Service) GetSystemInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*SystemInfo, error) { - var systemInfo SystemInfo - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointSystemInfo, - Result: &systemInfo, - }) - if err != nil { - return nil, err - } - return &systemInfo, nil -} - -func (s *Service) GetMinerSummary(ctx context.Context, connInfo *AntminerConnectionInfo) (*MinerSummary, error) { - var summary MinerSummary - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointMinerSummary, - Result: &summary, - }) - if err != nil { - return nil, err - } - return &summary, nil -} - -func (s *Service) GetMinerConfig(ctx context.Context, connInfo *AntminerConnectionInfo) (*MinerConfig, error) { - var config MinerConfig - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointMinerConfig, - Result: &config, - }) - if err != nil { - return nil, err - } - return &config, nil -} - -func (s *Service) GetNetworkInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*NetworkInfo, error) { - var networkInfo NetworkInfo - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointNetworkInfo, - Result: &networkInfo, - }) - if err != nil { - return nil, err - } - return &networkInfo, nil -} - -func (s *Service) GetStatsInfo(ctx context.Context, connInfo *AntminerConnectionInfo) (*StatsInfo, error) { - var statsInfo StatsInfo - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointStats, - Result: &statsInfo, - }) - if err != nil { - return nil, err - } - return &statsInfo, nil -} - -func (s *Service) SetMinerConfig(ctx context.Context, connInfo *AntminerConnectionInfo, config *MinerConfig) error { - return s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodPost, - Endpoint: endpointSetConfig, - Body: config, - }) -} - -func (s *Service) Reboot(ctx context.Context, connInfo *AntminerConnectionInfo) error { - return s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodPost, - Endpoint: endpointReboot, - }) -} - -func (s *Service) StartBlink(ctx context.Context, connInfo *AntminerConnectionInfo) error { - return s.setBlink(ctx, connInfo, true) -} - -func (s *Service) StopBlink(ctx context.Context, connInfo *AntminerConnectionInfo) error { - return s.setBlink(ctx, connInfo, false) -} - -func (s *Service) setBlink(ctx context.Context, connInfo *AntminerConnectionInfo, blink bool) error { - blinkData := map[string]string{ - "blink": fmt.Sprintf("%t", blink), - } - - return s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodPost, - Endpoint: endpointBlink, - Body: blinkData, - }) -} - -func (s *Service) GetKernelLog(ctx context.Context, connInfo *AntminerConnectionInfo) (string, error) { - logs, err := s.requestRaw(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointKernelLog, - }) - if err == nil { - return logs, nil - } - if !errors.Is(err, errEndpointNotFound) { - return "", err - } - // Older Antminer firmware (e.g., S19) uses /cgi-bin/log.cgi instead. - return s.requestRaw(ctx, connInfo, RequestOptions{ - Method: http.MethodGet, - Endpoint: endpointLegacyLog, - }) -} - -func (s *Service) ChangePassword(ctx context.Context, connInfo *AntminerConnectionInfo, currentPassword, newPassword string) error { - passwordData := map[string]string{ - "curPwd": currentPassword, - "newPwd": newPassword, - "confirmPwd": newPassword, - } - - var result struct { - Stats string `json:"stats"` - Code string `json:"code"` - Msg string `json:"msg"` - } - - err := s.request(ctx, connInfo, RequestOptions{ - Method: http.MethodPost, - Endpoint: endpointPassword, - Body: passwordData, - Result: &result, - }) - if err != nil { - return fmt.Errorf("failed to change password: %w", err) - } - - if result.Stats != "success" { - return fmt.Errorf("password change failed: %s (code: %s)", result.Msg, result.Code) - } - - return nil -} - -// UploadFirmware uploads a firmware file to the Antminer via the CGI upgrade -// endpoint (POST /cgi-bin/upgrade.cgi, multipart/form-data with digest auth). -// The file is streamed from firmware.Reader without buffering in memory. -func (s *Service) UploadFirmware(ctx context.Context, connInfo *AntminerConnectionInfo, firmware sdk.FirmwareFile) error { - if firmware.Reader == nil { - return fmt.Errorf("firmware reader is required") - } - - reqURL := s.buildURL(connInfo, endpointUpgrade) - - ctx, cancel := context.WithTimeout(ctx, firmwareUploadTimeout) - defer cancel() - - pr, pw := io.Pipe() - defer pr.Close() - - mw := multipart.NewWriter(pw) - - // Channel captures the goroutine's outcome so we can confirm the entire - // multipart body was written before reporting success. - writerDone := make(chan error, 1) - - go func() { - defer pw.Close() - - part, err := mw.CreateFormFile("file", firmware.Filename) - if err != nil { - pw.CloseWithError(fmt.Errorf("failed to create multipart form file: %w", err)) - writerDone <- err - return - } - - if _, err := io.Copy(part, firmware.Reader); err != nil { - pw.CloseWithError(fmt.Errorf("failed to write firmware data: %w", err)) - writerDone <- err - return - } - - if err := mw.Close(); err != nil { - pw.CloseWithError(fmt.Errorf("failed to close multipart writer: %w", err)) - writerDone <- err - return - } - - writerDone <- nil - }() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, pr) - if err != nil { - return fmt.Errorf("failed to create firmware upload request: %w", err) - } - req.Header.Set("Content-Type", mw.FormDataContentType()) - - if connInfo.Creds.Username != "" && connInfo.Creds.Password != "" { - if err := s.addDigestAuth(req, connInfo.Creds); err != nil { - return fmt.Errorf("failed to add digest auth for firmware upload: %w", err) - } - } - - // Use a client without the default 30s timeout — firmware uploads can take - // much longer. The context timeout above controls the overall deadline. - transport := s.httpClient.Transport - if transport == nil { - transport = http.DefaultTransport - } - uploadClient := &http.Client{Transport: transport} - - resp, err := uploadClient.Do(req) - if err != nil { - return fmt.Errorf("failed to upload firmware: %w", err) - } - defer resp.Body.Close() - _, _ = io.Copy(io.Discard, resp.Body) - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized { - return sdk.NewErrorAuthenticationFailed(connInfo.GetURL().String()) - } - return fmt.Errorf("firmware upload failed with status %d", resp.StatusCode) - } - - if err := <-writerDone; err != nil { - return fmt.Errorf("firmware upload: multipart writer failed: %w", err) - } - - return nil -} - -func (s *Service) addDigestAuth(req *http.Request, creds sdk.UsernamePassword) error { - challengeReq, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), nil) - if err != nil { - return fmt.Errorf("failed to create challenge request: %w", err) - } - - challengeResp, err := s.httpClient.Do(challengeReq) - if err != nil { - return fmt.Errorf("failed to get auth challenge: %w", err) - } - defer challengeResp.Body.Close() - - if challengeResp.StatusCode != http.StatusUnauthorized { - return nil - } - - authHeader := challengeResp.Header.Get("WWW-Authenticate") - if authHeader == "" { - return fmt.Errorf("no WWW-Authenticate header found") - } - - challenge, err := parseDigestChallenge(authHeader) - if err != nil { - return fmt.Errorf("failed to parse digest challenge: %w", err) - } - - digestAuth, err := generateDigestResponse(challenge, creds, req.Method, req.URL.Path) - if err != nil { - return fmt.Errorf("failed to generate digest response: %w", err) - } - - authHeaderValue := buildAuthorizationHeader(digestAuth) - req.Header.Set("Authorization", authHeaderValue) - - return nil -} - -func parseDigestChallenge(authHeader string) (*DigestChallenge, error) { - if !strings.HasPrefix(strings.ToLower(authHeader), "digest ") { - return nil, fmt.Errorf("not a digest authentication challenge") - } - - params := strings.TrimPrefix(authHeader, "Digest ") - params = strings.TrimPrefix(params, "digest ") - - challenge := &DigestChallenge{ - Algorithm: "MD5", // Default algorithm - } - - paramRegex := regexp.MustCompile(`(\w+)=(?:"([^"]+)"|([^,\s]+))`) - matches := paramRegex.FindAllStringSubmatch(params, -1) - - for _, match := range matches { - key := strings.ToLower(match[1]) - value := match[2] - if value == "" { - value = match[3] - } - - switch key { - case "realm": - challenge.Realm = value - case "nonce": - challenge.Nonce = value - case "opaque": - challenge.Opaque = value - case "algorithm": - challenge.Algorithm = value - case "qop": - challenge.QOP = value - } - } - - if challenge.Realm == "" || challenge.Nonce == "" { - return nil, fmt.Errorf("missing required digest parameters") - } - - return challenge, nil -} - -func generateDigestResponse(challenge *DigestChallenge, creds sdk.UsernamePassword, method, uri string) (*DigestAuth, error) { - cnonce, err := generateCNonce() - if err != nil { - return nil, fmt.Errorf("failed to generate cnonce: %w", err) - } - - nc := "00000001" // Nonce count - - auth := &DigestAuth{ - creds: creds, - Realm: challenge.Realm, - Nonce: challenge.Nonce, - URI: uri, - Algorithm: challenge.Algorithm, - Opaque: challenge.Opaque, - QOP: challenge.QOP, - NC: nc, - CNonce: cnonce, - } - - response := calculateDigestResponse(auth, method) - auth.Response = response - return auth, nil -} - -func calculateDigestResponse(auth *DigestAuth, method string) string { - ha1 := md5Hash(fmt.Sprintf("%s:%s:%s", auth.creds.Username, auth.Realm, auth.creds.Password)) - ha2 := md5Hash(fmt.Sprintf("%s:%s", method, auth.URI)) - - var response string - if auth.QOP == "auth" || auth.QOP == "auth-int" { - response = md5Hash(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, auth.Nonce, auth.NC, auth.CNonce, auth.QOP, ha2)) - } else { - response = md5Hash(fmt.Sprintf("%s:%s:%s", ha1, auth.Nonce, ha2)) - } - - return response -} - -func buildAuthorizationHeader(auth *DigestAuth) string { - var parts []string - - parts = append(parts, fmt.Sprintf(`username="%s"`, auth.creds.Username)) - parts = append(parts, fmt.Sprintf(`realm="%s"`, auth.Realm)) - parts = append(parts, fmt.Sprintf(`nonce="%s"`, auth.Nonce)) - parts = append(parts, fmt.Sprintf(`uri="%s"`, auth.URI)) - parts = append(parts, fmt.Sprintf(`response="%s"`, auth.Response)) - - if auth.Algorithm != "" { - parts = append(parts, fmt.Sprintf(`algorithm=%s`, auth.Algorithm)) - } - - if auth.Opaque != "" { - parts = append(parts, fmt.Sprintf(`opaque="%s"`, auth.Opaque)) - } - - if auth.QOP != "" { - parts = append(parts, fmt.Sprintf(`qop=%s`, auth.QOP)) - parts = append(parts, fmt.Sprintf(`nc=%s`, auth.NC)) - parts = append(parts, fmt.Sprintf(`cnonce="%s"`, auth.CNonce)) - } - - return "Digest " + strings.Join(parts, ", ") -} - -func generateCNonce() (string, error) { - b := make([]byte, cnonceBufferSize) - n, err := rand.Read(b) - if err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - if n != len(b) { - return "", fmt.Errorf("failed to generate enough random bytes") - } - return hex.EncodeToString(b), nil -} - -// md5Hash creates an MD5 hash of the input string -// #nosec G401 - MD5 is required for digest authentication with Antminer devices -func md5Hash(text string) string { - hash := md5.Sum([]byte(text)) - return hex.EncodeToString(hash[:]) -} diff --git a/plugin/antminer/pkg/antminer/web/service_test.go b/plugin/antminer/pkg/antminer/web/service_test.go deleted file mode 100644 index 3fde408eb9..0000000000 --- a/plugin/antminer/pkg/antminer/web/service_test.go +++ /dev/null @@ -1,812 +0,0 @@ -package web_test - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/networking" - "github.com/block/proto-fleet/plugin/antminer/pkg/antminer/web" - "github.com/block/proto-fleet/server/sdk/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// newTestAntminerConnectionInfo creates an AntminerConnectionInfo from a URL for testing -func newTestAntminerConnectionInfo(t *testing.T, urlStr string, creds sdk.UsernamePassword) *web.AntminerConnectionInfo { - t.Helper() - parsedURL, err := url.Parse(urlStr) - require.NoError(t, err) - - host := parsedURL.Hostname() - port := parsedURL.Port() - if port == "" { - port = "80" - } - - protocol, err := networking.ProtocolFromString(parsedURL.Scheme) - require.NoError(t, err) - - connInfo, err := networking.NewConnectionInfo(host, port, protocol) - require.NoError(t, err) - - return web.NewAntminerConnectionInfo(*connInfo, creds) -} - -func TestGetSystemInfo(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/get_system_info.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{ - "minertype": "Antminer S21", - "nettype": "DHCP", - "netdevice": "eth0", - "macaddr": "02:50:53:09:DA:D9", - "hostname": "Antminer", - "ipaddress": "127.0.0.1", - "netmask": "255.255.255.0", - "gateway": "", - "dnsservers": "", - "system_mode": "GNU/Linux", - "system_kernel_version": "Linux 4.9.113 #1 SMP PREEMPT Thu Jul 11 17:01:13 CST 2024", - "system_filesystem_version": "Thu Jul 11 16:38:25 CST 2024", - "firmware_type": "Release", - "serinum": "SMTTATUBDJAAI00A5" - }`)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - systemInfo, err := service.GetSystemInfo(t.Context(), connInfo) - - // Assert - require.NoError(t, err) - assert.NotZero(t, systemInfo) - assert.Equal(t, "Antminer S21", systemInfo.MinerType) - assert.Equal(t, "DHCP", systemInfo.NetType) - assert.Equal(t, "SMTTATUBDJAAI00A5", systemInfo.SerialNumber) -} - -func TestGetMinerSummary(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/summary.cgi", r.URL.Path) - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{ - "STATUS": [{"STATUS": "S", "When": 1750192565, "Msg": "summary", "Code": 0, "Description": ""}], - "INFO": {"miner_version": "uart_trans.1.3", "CompileTime": "Thu Jul 11 16:38:25 CST 2024", "type": "Antminer S21"}, - "SUMMARY": [{ - "elapsed": 3817, - "rate_5s": 206238.69, - "rate_30m": 204185.62, - "rate_avg": 203719.72, - "rate_ideal": 200000.0, - "rate_unit": "GH/s", - "hw_all": 2, - "bestshare": 727920402, - "status": [ - {"type": "rate", "status": "s", "code": 0, "msg": ""}, - {"type": "network", "status": "s", "code": 0, "msg": ""}, - {"type": "fans", "status": "s", "code": 0, "msg": ""}, - {"type": "temp", "status": "s", "code": 0, "msg": ""} - ] - }] - }`)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - summary, err := service.GetMinerSummary(t.Context(), connInfo) - - // Assert - require.NoError(t, err) - assert.NotZero(t, summary) - require.NotEmpty(t, summary.Status) - assert.Equal(t, "S", summary.Status[0].Status) - assert.Equal(t, "summary", summary.Status[0].Msg) - assert.Equal(t, "Antminer S21", summary.Info.Type) - assert.InEpsilon(t, float64(206238.69), summary.Summary[0].Rate5s, 0.01) - assert.Equal(t, "GH/s", summary.Summary[0].RateUnit) -} - -func TestGetMinerConfig(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/get_miner_conf.cgi", r.URL.Path) - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{ - "pools": [ - { - "url": "stratum+tcp://stratum.example.com:3333", - "user": "proto_mining_sw_test", - "pass": "test-password" - }, - { - "url": "", - "user": "", - "pass": "" - }, - { - "url": "", - "user": "", - "pass": "" - } - ], - "api-listen": true, - "api-network": true, - "api-groups": "A:stats:pools:devs:summary:version", - "api-allow": "A:0/0,W:*", - "bitmain-fan-ctrl": false, - "bitmain-fan-pwm": "100", - "bitmain-use-vil": true, - "bitmain-freq": "200", - "bitmain-voltage": "1320", - "bitmain-ccdelay": "0", - "bitmain-pwth": "3", - "bitmain-work-mode": "0", - "bitmain-hashrate-percent": "100", - "bitmain-freq-level": "100" - }`)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - config, err := service.GetMinerConfig(t.Context(), connInfo) - - require.NoError(t, err) - assert.NotZero(t, config) - assert.Equal(t, "stratum+tcp://stratum.example.com:3333", config.Pools[0].URL) - assert.Equal(t, "proto_mining_sw_test", config.Pools[0].Username) - assert.Equal(t, "100", config.BitmainFanPWM) - assert.Equal(t, "200", config.BitmainFreq) -} - -func TestGetNetworkInfo(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/get_network_info.cgi", r.URL.Path) - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{ - "nettype": "DHCP", - "netdevice": "eth0", - "macaddr": "02:50:53:09:DA:D9", - "ipaddress": "127.0.0.1", - "netmask": "255.255.255.0", - "conf_nettype": "DHCP", - "conf_hostname": "Antminer", - "conf_ipaddress": "", - "conf_netmask": "", - "conf_gateway": "", - "conf_dnsservers": "" - }`)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - networkInfo, err := service.GetNetworkInfo(t.Context(), connInfo) - - require.NoError(t, err) - assert.NotZero(t, networkInfo) - assert.Equal(t, "DHCP", networkInfo.NetType) - assert.Equal(t, "eth0", networkInfo.NetDevice) - assert.Equal(t, "02:50:53:09:DA:D9", networkInfo.MacAddr) - assert.Equal(t, "127.0.0.1", networkInfo.IPAddress) -} - -func TestSetMinerConfig(t *testing.T) { - authRequested := false - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/cgi-bin/set_miner_conf.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" && !authRequested { - authRequested = true - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - contentType := r.Header.Get("Content-Type") - assert.Equal(t, "application/json", contentType) - - var config web.MinerConfig - err := json.NewDecoder(r.Body).Decode(&config) - assert.NoError(t, err) - - assert.Equal(t, "stratum+tcp://pool.example.com:3333", config.Pools[0].URL) - assert.Equal(t, "username.worker", config.Pools[0].Username) - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - config := &web.MinerConfig{ - Pools: []web.Pool{ - { - URL: "stratum+tcp://pool.example.com:3333", - Username: "username.worker", - Password: "x", - }, - }, - BitmainFanPWM: "100", - BitmainFreqLevel: "100", - } - - err := service.SetMinerConfig(t.Context(), connInfo, config) - - require.NoError(t, err) -} - -func TestReboot(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - err := service.Reboot(t.Context(), connInfo) - - require.NoError(t, err) -} - -func TestBlink(t *testing.T) { - testCases := []struct { - name string - blinkOn bool - testFunc func(*web.Service, context.Context, *web.AntminerConnectionInfo) error - }{ - { - name: "StartBlink", - blinkOn: true, - testFunc: func(s *web.Service, ctx context.Context, conn *web.AntminerConnectionInfo) error { - return s.StartBlink(ctx, conn) - }, - }, - { - name: "StopBlink", - blinkOn: false, - testFunc: func(s *web.Service, ctx context.Context, conn *web.AntminerConnectionInfo) error { - return s.StopBlink(ctx, conn) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - authRequested := false - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/cgi-bin/blink.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" && !authRequested { - authRequested = true - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - contentType := r.Header.Get("Content-Type") - assert.Equal(t, "application/json", contentType) - - var blinkData map[string]string - err := json.NewDecoder(r.Body).Decode(&blinkData) - assert.NoError(t, err) - - expectedValue := "true" - if !tc.blinkOn { - expectedValue = "false" - } - assert.Equal(t, expectedValue, blinkData["blink"]) - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - err := tc.testFunc(service, t.Context(), connInfo) - - require.NoError(t, err) - }) - } -} - -func TestGetStatsInfo(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/stats.cgi", r.URL.Path) - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{ - "STATUS": {"STATUS": "S", "when": 1766099123, "Msg": "stats", "api_version": "1.0.0"}, - "INFO": {"miner_version": "uart_trans.1.3", "CompileTime": "Thu Jul 11 16:38:25 CST 2024", "type": "Antminer S21"}, - "STATS": [{ - "elapsed": 8152, - "rate_5s": 206901.8, - "rate_30m": 204393.1, - "rate_avg": 203856.36, - "rate_ideal": 200000.0, - "rate_unit": "GH/s", - "chain_num": 3, - "fan_num": 4, - "fan": [7000, 7000, 7000, 7000], - "psu": {"index": 0, "status": "ok"}, - "hwp_total": 0.0006, - "chain": [ - { - "index": 0, - "freq_avg": 490, - "rate_ideal": 67525.0, - "rate_real": 67293.41, - "asic_num": 108, - "temp_pic": [44, 44, 58, 58], - "temp_pcb": [54, 54, 68, 68], - "temp_chip": [59, 59, 73, 73], - "hw": 0, - "sn": "SMTTYRHBDJAAI019D", - "hwp": 0.0 - }, - { - "index": 1, - "freq_avg": 490, - "rate_ideal": 67525.0, - "rate_real": 68916.75, - "asic_num": 108, - "temp_pic": [44, 44, 57, 57], - "temp_pcb": [54, 54, 67, 67], - "temp_chip": [59, 59, 72, 72], - "hw": 1, - "sn": "SMTTYRHBDJAAI019N", - "hwp": 0.001 - }, - { - "index": 2, - "freq_avg": 490, - "rate_ideal": 67525.0, - "rate_real": 70691.63, - "asic_num": 108, - "temp_pic": [44, 44, 58, 58], - "temp_pcb": [54, 54, 68, 68], - "temp_chip": [59, 59, 73, 73], - "hw": 1, - "sn": "SMTTYRHBDJAAI019S", - "hwp": 0.001 - } - ] - }] - }`)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - stats, err := service.GetStatsInfo(t.Context(), connInfo) - - // Assert - require.NoError(t, err) - assert.NotZero(t, stats) - assert.Equal(t, "S", stats.STATUS.Status) - assert.Equal(t, "Antminer S21", stats.INFO.Type) - require.NotEmpty(t, stats.STATS) - assert.Equal(t, 3, stats.STATS[0].ChainNum) - assert.Equal(t, 4, stats.STATS[0].FanNum) - assert.Len(t, stats.STATS[0].Fan, 4) - assert.Equal(t, 7000, stats.STATS[0].Fan[0]) - require.NotNil(t, stats.STATS[0].PSU) - assert.Equal(t, 0, stats.STATS[0].PSU.Index) - assert.Equal(t, "ok", stats.STATS[0].PSU.Status) - assert.Len(t, stats.STATS[0].Chain, 3) - assert.Len(t, stats.STATS[0].Chain[0].TempChip, 4) - assert.InEpsilon(t, 59.0, stats.STATS[0].Chain[0].TempChip[0], 0.01) - assert.InEpsilon(t, 73.0, stats.STATS[0].Chain[0].TempChip[2], 0.01) -} - -func TestGetKernelLog(t *testing.T) { - expectedLog := `[ 0.000000] Booting Linux on physical CPU 0x0 -[ 0.000000] Linux version 4.9.113 (root@builder) (gcc version 6.4.0) -[ 12.345678] cgminer: Starting mining operations -[ 12.456789] cgminer: Connected to pool stratum+tcp://pool.example.com:3333 -[ 45.678901] Temperature warning: Chain 0 reached 75C` - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/cgi-bin/get_kernel_log.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(expectedLog)) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - log, err := service.GetKernelLog(t.Context(), connInfo) - - require.NoError(t, err) - assert.Equal(t, expectedLog, log) - assert.Contains(t, log, "cgminer") - assert.Contains(t, log, "Temperature warning") -} - -func TestErrorHandling(t *testing.T) { - testCases := []struct { - name string - statusCode int - endpoint string - }{ - { - name: "Unauthorized", - statusCode: http.StatusUnauthorized, - endpoint: "/cgi-bin/get_system_info.cgi", - }, - { - name: "NotFound", - statusCode: http.StatusNotFound, - endpoint: "/cgi-bin/get_system_info.cgi", - }, - { - name: "ServerError", - statusCode: http.StatusInternalServerError, - endpoint: "/cgi-bin/get_system_info.cgi", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(tc.statusCode) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{}) - - _, err := service.GetSystemInfo(t.Context(), connInfo) - - require.Error(t, err) - }) - } -} - -func TestChangePassword(t *testing.T) { - t.Run("success", func(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/cgi-bin/passwd.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - // Decode request body - var req map[string]string - err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - - assert.Equal(t, "oldpassword", req["curPwd"]) - assert.Equal(t, "newpassword", req["newPwd"]) - assert.Equal(t, "newpassword", req["confirmPwd"]) - - w.WriteHeader(http.StatusOK) - _, err = w.Write([]byte(`{"stats":"success","code":"P000","msg":"OK!"}`)) - require.NoError(t, err) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - err := service.ChangePassword(t.Context(), connInfo, "oldpassword", "newpassword") - - // Assert - require.NoError(t, err) - }) - - t.Run("wrong current password", func(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.WriteHeader(http.StatusUnauthorized) - _, err := w.Write([]byte(`{"stats":"error","code":"P002","msg":"Current password incorrect"}`)) - require.NoError(t, err) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - err := service.ChangePassword(t.Context(), connInfo, "wrongpassword", "newpassword") - - // Assert - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to change password") - }) - - t.Run("password mismatch", func(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.WriteHeader(http.StatusBadRequest) - _, err := w.Write([]byte(`{"stats":"error","code":"P003","msg":"New password and confirmation do not match"}`)) - require.NoError(t, err) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - err := service.ChangePassword(t.Context(), connInfo, "oldpassword", "newpassword") - - // Assert - require.Error(t, err) - }) - - t.Run("api failure", func(t *testing.T) { - // Arrange - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"stats":"error","code":"P999","msg":"Unknown error"}`)) - require.NoError(t, err) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - // Act - err := service.ChangePassword(t.Context(), connInfo, "oldpassword", "newpassword") - - // Assert - require.Error(t, err) - assert.Contains(t, err.Error(), "password change failed") - }) -} - -func TestUploadFirmware(t *testing.T) { - firmwareContent := []byte("fake-firmware-content-for-test") - - t.Run("successful upload with digest auth", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/cgi-bin/upgrade.cgi", r.URL.Path) - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - assert.True(t, strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")) - - file, header, err := r.FormFile("file") - require.NoError(t, err, "should be able to read 'file' field") - defer file.Close() - - assert.Equal(t, "firmware.tar.gz", header.Filename) - body, err := io.ReadAll(file) - require.NoError(t, err) - assert.Equal(t, firmwareContent, body) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("System Upgrade Successed")) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - firmware := sdk.FirmwareFile{ - Reader: bytes.NewReader(firmwareContent), - Filename: "firmware.tar.gz", - Size: int64(len(firmwareContent)), - } - - err := service.UploadFirmware(t.Context(), connInfo, firmware) - require.NoError(t, err) - }) - - t.Run("auth failure (401)", func(t *testing.T) { - challengeSent := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !challengeSent { - challengeSent = true - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "wrong"}) - - firmware := sdk.FirmwareFile{ - Reader: bytes.NewReader(firmwareContent), - Filename: "firmware.tar.gz", - Size: int64(len(firmwareContent)), - } - - err := service.UploadFirmware(t.Context(), connInfo, firmware) - require.Error(t, err) - assert.Contains(t, err.Error(), "authentication") - }) - - t.Run("server error (500)", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - firmware := sdk.FirmwareFile{ - Reader: bytes.NewReader(firmwareContent), - Filename: "firmware.tar.gz", - Size: int64(len(firmwareContent)), - } - - err := service.UploadFirmware(t.Context(), connInfo, firmware) - require.Error(t, err) - assert.Contains(t, err.Error(), "firmware upload failed with status 500") - }) - - t.Run("no credentials skips auth", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Empty(t, r.Header.Get("Authorization"), "no auth header when credentials are empty") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{}) - - firmware := sdk.FirmwareFile{ - Reader: bytes.NewReader(firmwareContent), - Filename: "firmware.tar.gz", - Size: int64(len(firmwareContent)), - } - - err := service.UploadFirmware(t.Context(), connInfo, firmware) - require.NoError(t, err) - }) - - t.Run("nil reader returns error", func(t *testing.T) { - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, "http://localhost", sdk.UsernamePassword{Username: "root", Password: "root"}) - - firmware := sdk.FirmwareFile{ - Filename: "firmware.tar.gz", - Size: 100, - } - - err := service.UploadFirmware(t.Context(), connInfo, firmware) - require.Error(t, err) - assert.Contains(t, err.Error(), "firmware reader is required") - }) - - t.Run("context cancellation", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("WWW-Authenticate", `Digest realm="antminer", nonce="1234567890abcdef", algorithm=MD5, qop="auth"`) - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - service := web.NewService() - connInfo := newTestAntminerConnectionInfo(t, server.URL, sdk.UsernamePassword{Username: "root", Password: "root"}) - - ctx, cancel := context.WithCancel(t.Context()) - cancel() - - firmware := sdk.FirmwareFile{ - Reader: bytes.NewReader(firmwareContent), - Filename: "firmware.tar.gz", - Size: int64(len(firmwareContent)), - } - - err := service.UploadFirmware(ctx, connInfo, firmware) - require.Error(t, err) - }) -} diff --git a/plugin/antminer/pkg/antminer/web/type.go b/plugin/antminer/pkg/antminer/web/type.go deleted file mode 100644 index 9bc120a890..0000000000 --- a/plugin/antminer/pkg/antminer/web/type.go +++ /dev/null @@ -1,5 +0,0 @@ -package web - -import "github.com/block/proto-fleet/server/sdk/v1" - -type CoolingMode sdk.CoolingMode diff --git a/plugin/asicrs/Cargo.lock b/plugin/asicrs/Cargo.lock index a65feb8bd0..4ed185c878 100644 --- a/plugin/asicrs/Cargo.lock +++ b/plugin/asicrs/Cargo.lock @@ -45,9 +45,8 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "asic-rs" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732900593524d4b7cd12496edd434bd04b977e6e9a82020d1e0176f64a97f0c5" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -60,6 +59,7 @@ dependencies = [ "asic-rs-firmwares-luxminer", "asic-rs-firmwares-marathon", "asic-rs-firmwares-nerdaxe", + "asic-rs-firmwares-proto", "asic-rs-firmwares-sealminer", "asic-rs-firmwares-vnish", "asic-rs-firmwares-whatsminer", @@ -71,6 +71,7 @@ dependencies = [ "asic-rs-makes-epic", "asic-rs-makes-marathon", "asic-rs-makes-nerdaxe", + "asic-rs-makes-proto", "asic-rs-makes-sealminer", "asic-rs-makes-whatsminer", "async-stream", @@ -89,9 +90,8 @@ dependencies = [ [[package]] name = "asic-rs-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeca12472b6dd7def9f94ee68da61ef59df74af270113465b0c1c43602dee713" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "async-trait", @@ -111,9 +111,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-antminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca361ebce0903fc76f53f02e4a84c629108104cccd03e8c9ec952e5c91e4771" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -133,9 +132,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-auradine" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f749ea2cd25de7cf78cb81b8c840c253a591b465154012e78ce72c245f35aeb" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -152,9 +150,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-avalonminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bb5e0fb60ef1a1376d2e39398add3a5a8213ad117fae705dca9449edcd787d" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -170,9 +167,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-bitaxe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72a5c782b239992a951e4b7e4f768a86a4710e7280352e9affadeb1be20af6e" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -189,9 +185,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-braiins" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107381781dbbfb58a3c934616fcf9a0432a6372845709bf9f2cb37f155368d19" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -210,9 +205,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-epic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710384594a078537b797c1b554bcdd198c1168f63480b7aa7dce263635c5d148" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -233,9 +227,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-luxminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb6a758b0b55b0343b02165c6ee2acf51dc9ebffd0b26fafa902db2ede2dac5" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -250,9 +243,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-marathon" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e9430cde91d0dc1a38e20087052e89a1c6afe9f097b2e684e47f788da5b2f81" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -270,9 +262,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-nerdaxe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eed805108efe6a60a616ebd6eabcd8c17da05dbd08dbc2bb11868e955848b3" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -287,11 +278,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "asic-rs-firmwares-proto" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" +dependencies = [ + "anyhow", + "asic-rs-core", + "asic-rs-makes-proto", + "async-trait", + "macaddr", + "measurements", + "once_cell", + "reqwest", + "semver", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "asic-rs-firmwares-sealminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf4d189777ea4648d714c86750f19fe79913b86ad8f5c378531e9e1652b99c8" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -308,9 +317,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-vnish" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d73fa4d2139ada9dce1fbd9780ea614fc5a278395b3104c98a33d4df8c588bc9" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "anyhow", "asic-rs-core", @@ -327,9 +335,8 @@ dependencies = [ [[package]] name = "asic-rs-firmwares-whatsminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3694acf569361130a550cbc5250e45b2a2b8216a831686534498f399ee2eff5" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "aes", "anyhow", @@ -353,9 +360,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-antminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773b12751105313922e48a9f0ffaf78026f271750ac774dfd7affe5c419d3be2" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -365,9 +371,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-auradine" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e36e2ac9f880f2340341be529ce5caf5392cb19c35bd14968bae3733dd5c3b6" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -377,9 +382,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-avalon" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d5d426d5dd00f62f371b9beacd6c388c028086362fd9cc9c9e5af3c7f806f6" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -389,9 +393,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-bitaxe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8b00dc67f3189476f1fe47e755361f65fbe96eafb1bf23efcd2278e2cc5616" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -401,9 +404,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-braiins" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e3cea91dee516acabe06d0240262f546bafc02da76a51b2da10fcdb35395ca" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -413,9 +415,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-epic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51f7942db7a1d1c11ddf645a792130f1c799d6fe20121681eb53264bebea0d7" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -425,20 +426,30 @@ dependencies = [ [[package]] name = "asic-rs-makes-marathon" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7073ce641802deb8dc2ffdaac7154af59c86b2ad0f3106b954571812f94fc970" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", + "serde_json", "strum", ] [[package]] name = "asic-rs-makes-nerdaxe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b114530d0ba0443629d643f3986d3ca0c0541f714fea32488021a54e70ddc0ce" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" +dependencies = [ + "asic-rs-core", + "serde", + "serde_json", + "strum", +] + +[[package]] +name = "asic-rs-makes-proto" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -448,9 +459,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-sealminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cb5cef195f469fb8732284cb58ee2d31e7bd2b6f010f0c3cd0b30ba39cc129" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -460,9 +470,8 @@ dependencies = [ [[package]] name = "asic-rs-makes-whatsminer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4928b3a6fc98e69b472fd4c72b044ccf8ccb47d516c52953a8db7b6242c15d61" +version = "0.6.0" +source = "git+https://github.com/256foundation/asic-rs.git?rev=43d706ef31aa154f8bea0bd7ef8e81caa9cbe491#43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" dependencies = [ "asic-rs-core", "serde", @@ -477,11 +486,15 @@ dependencies = [ "anyhow", "asic-rs", "asic-rs-core", + "chrono", + "digest_auth", "futures", "measurements", "prost-types", "proto-fleet-plugin", + "reqwest", "serde", + "serde_json", "serde_yaml_ng", "tokio", "tokio-stream", @@ -620,9 +633,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -656,9 +669,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -691,9 +704,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1079,9 +1092,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1125,9 +1138,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1170,9 +1183,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1478,13 +1491,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1535,9 +1547,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -1606,9 +1618,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -2024,9 +2036,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2047,9 +2059,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2159,9 +2171,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2370,9 +2382,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2414,9 +2426,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -2473,9 +2485,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2804,9 +2816,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -2895,9 +2907,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -2913,9 +2925,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2926,9 +2938,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -2936,9 +2948,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2946,9 +2958,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -2959,9 +2971,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -3015,9 +3027,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3368,9 +3380,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3391,18 +3403,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -3432,9 +3444,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/plugin/asicrs/Cargo.toml b/plugin/asicrs/Cargo.toml index 7a318d307a..ecbd2f31fc 100644 --- a/plugin/asicrs/Cargo.toml +++ b/plugin/asicrs/Cargo.toml @@ -16,8 +16,8 @@ tokio-stream = "0.1" prost-types = "0.13" # asic-rs miner library -asic-rs = "0.5.3" -asic-rs-core = "0.5.3" +asic-rs = { git = "https://github.com/256foundation/asic-rs.git", rev = "43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" } +asic-rs-core = { git = "https://github.com/256foundation/asic-rs.git", rev = "43d706ef31aa154f8bea0bd7ef8e81caa9cbe491" } measurements = "0.11" # config @@ -31,3 +31,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # utils anyhow = "1" futures = "0.3" +reqwest = { version = "0.13.3", features = ["json", "gzip", "rustls", "multipart", "stream"], default-features = false } +digest_auth = "0.3" +serde_json = "1" +chrono = "0.4" diff --git a/plugin/asicrs/config.yaml b/plugin/asicrs/config.yaml index 0eff716d40..ef1e920352 100644 --- a/plugin/asicrs/config.yaml +++ b/plugin/asicrs/config.yaml @@ -10,7 +10,7 @@ miners: antminer: stock: - enabled: false # Go plugin handles stock Antminers + enabled: true braiins: enabled: true vnish: diff --git a/plugin/asicrs/src/capabilities.rs b/plugin/asicrs/src/capabilities.rs index e12f5f9e99..c86954e0b3 100644 --- a/plugin/asicrs/src/capabilities.rs +++ b/plugin/asicrs/src/capabilities.rs @@ -29,6 +29,8 @@ const DISPLAY_EPIC: &str = "ePIC"; const DISPLAY_BRAIINS: &str = "Braiins"; const DISPLAY_LUXOS: &str = "LuxOS"; const DISPLAY_MARATHON: &str = "Marathon"; +const DISPLAY_BITMAIN: &str = "Bitmain"; +const DISPLAY_ANTMINER_MODEL_PREFIX: &str = "Antminer"; /// Static base capabilities built once and cloned on use. static BASE_CAPABILITIES: LazyLock = LazyLock::new(|| { @@ -83,7 +85,9 @@ static BASE_CAPABILITIES: LazyLock = LazyLock::new(|| { caps.insert(CAP_LOGS_DOWNLOAD.into(), false); caps.insert(CAP_OTA_UPDATE.into(), false); caps.insert(CAP_ASYMMETRIC_AUTH.into(), false); + caps.insert(CAP_UPDATE_FIRMWARE.into(), false); caps.insert(CAP_FIRMWARE.into(), false); + caps.insert(CAP_MANUAL_UPLOAD.into(), false); caps }); @@ -120,6 +124,11 @@ const PROBED_CAPS: &[&str] = &[ CAP_GET_MINING_POOLS, CAP_UPDATE_MINING_POOLS, CAP_POWER_MODE_EFFICIENCY, + CAP_UPDATE_MINER_PASSWORD, + CAP_LOGS_DOWNLOAD, + CAP_UPDATE_FIRMWARE, + CAP_FIRMWARE, + CAP_MANUAL_UPLOAD, ]; /// Probe capabilities from a live miner instance using asic-rs supports_*() methods. @@ -148,8 +157,17 @@ pub fn probe_capabilities(miner: &dyn Miner) -> Capabilities { CAP_POWER_MODE_EFFICIENCY.into(), miner.supports_tuning_config(), ); - // Do not advertise firmware updates until the update_firmware RPC is implemented. - caps.insert(CAP_FIRMWARE.into(), false); + + // Maintenance -- from live miner introspection + caps.insert( + CAP_UPDATE_MINER_PASSWORD.into(), + miner.supports_change_password(), + ); + caps.insert(CAP_LOGS_DOWNLOAD.into(), miner.supports_read_logs()); + let firmware = miner.supports_upgrade_firmware(); + caps.insert(CAP_UPDATE_FIRMWARE.into(), firmware); + caps.insert(CAP_FIRMWARE.into(), firmware); + caps.insert(CAP_MANUAL_UPLOAD.into(), firmware); caps } @@ -166,6 +184,7 @@ pub fn make_to_family(make: &str) -> Option<&'static str> { ( &[ FAMILY_ANTMINER, + DISPLAY_BITMAIN, VARIANT_VNISH, VARIANT_BRAIINS, VARIANT_LUXOS, @@ -264,6 +283,46 @@ pub fn firmware_manufacturer(variant: &str) -> Option<&'static str> { } } +fn is_antminer_make(make: &str) -> bool { + make.eq_ignore_ascii_case(FAMILY_ANTMINER) || make.eq_ignore_ascii_case(DISPLAY_BITMAIN) +} + +/// Map asic-rs make/firmware values to the manufacturer name Fleet historically exposed. +pub fn display_manufacturer(make: &str, variant: &str) -> String { + if let Some(manufacturer) = firmware_manufacturer(variant) { + return manufacturer.to_string(); + } + if is_antminer_make(make) { + DISPLAY_BITMAIN.to_string() + } else { + make.to_string() + } +} + +/// Map asic-rs model values to the model name Fleet historically exposed for stock Antminers. +pub fn display_model(make: &str, variant: &str, model: &str) -> String { + let model = model.trim(); + if variant == VARIANT_STOCK + && is_antminer_make(make) + && !model.is_empty() + && !model + .get(..DISPLAY_ANTMINER_MODEL_PREFIX.len().min(model.len())) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(DISPLAY_ANTMINER_MODEL_PREFIX)) + { + format!("{DISPLAY_ANTMINER_MODEL_PREFIX} {model}") + } else { + model.to_string() + } +} + +fn normalize_model_for_identity(model: &str) -> String { + let normalized = model.trim().to_ascii_lowercase(); + normalized + .strip_prefix("antminer ") + .unwrap_or(&normalized) + .to_string() +} + /// Verify that a miner's identity matches expected values. /// Requires at least one strong identifier (serial or MAC) to match when available. /// Exception: if the discovery record never captured serial/MAC (both empty), @@ -278,7 +337,11 @@ pub fn verify_identity( actual_mac: &str, ) -> Result<(), String> { // Check model (if available on both sides) - if !expected_model.is_empty() && !actual_model.is_empty() && actual_model != expected_model { + if !expected_model.is_empty() + && !actual_model.is_empty() + && normalize_model_for_identity(actual_model) + != normalize_model_for_identity(expected_model) + { return Err(format!( "model mismatch: expected '{}', got '{}'", expected_model, actual_model @@ -463,6 +526,7 @@ mod tests { // Verify every supported family resolves correctly assert_eq!(make_to_family("WhatsMiner"), Some(FAMILY_WHATSMINER)); assert_eq!(make_to_family("Antminer"), Some(FAMILY_ANTMINER)); + assert_eq!(make_to_family("Bitmain"), Some(FAMILY_ANTMINER)); assert_eq!(make_to_family("AvalonMiner"), Some(FAMILY_AVALONMINER)); assert_eq!(make_to_family("BitAxe"), Some(FAMILY_BITAXE)); assert_eq!(make_to_family("NerdAxe"), Some(FAMILY_NERDAXE)); @@ -484,4 +548,32 @@ mod tests { assert_eq!(make_to_family("LuxOS"), Some(FAMILY_ANTMINER)); assert_eq!(make_to_family("Marathon"), Some(FAMILY_ANTMINER)); } + + #[test] + fn test_stock_antminer_display_names_preserve_legacy_shape() { + assert_eq!( + display_manufacturer("Antminer", VARIANT_STOCK), + DISPLAY_BITMAIN + ); + assert_eq!( + display_model("Antminer", VARIANT_STOCK, "S19"), + "Antminer S19" + ); + } + + #[test] + fn test_aftermarket_display_names_remain_firmware_branded() { + assert_eq!( + display_manufacturer("Antminer", VARIANT_VNISH), + DISPLAY_VNISH + ); + assert_eq!(display_model("Antminer", VARIANT_VNISH, "S21Pro"), "S21Pro"); + } + + #[test] + fn test_identity_accepts_stock_antminer_raw_and_display_model() { + let result = verify_identity("Antminer S19", "", "", "S19", "", ""); + + assert!(result.is_ok()); + } } diff --git a/plugin/asicrs/src/device.rs b/plugin/asicrs/src/device.rs index 6d21ce687a..5410783656 100644 --- a/plugin/asicrs/src/device.rs +++ b/plugin/asicrs/src/device.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::time::Duration; @@ -5,17 +6,23 @@ use std::time::Duration; use asic_rs::MinerFactory; use asic_rs_core::config::pools::{PoolConfig, PoolGroupConfig}; use asic_rs_core::config::tuning::TuningConfig; +use asic_rs_core::data::firmware::FirmwareImage; use asic_rs_core::data::miner::{MinerData, MiningMode, TuningTarget}; use asic_rs_core::data::pool::PoolURL; -use asic_rs_core::traits::miner::{Miner, MinerAuth}; +use asic_rs_core::traits::miner::{ExposeSecret, Miner, MinerAuth}; +use chrono::{DateTime, Utc}; +use digest_auth::{AuthContext, HttpMethod}; use futures::FutureExt; use proto_fleet_plugin::capabilities::*; +use reqwest::header::AUTHORIZATION; use tokio::sync::Mutex; use tokio::time::Instant; use proto_fleet_plugin::pb; -use crate::capabilities::{probe_capabilities, verify_identity, Capabilities}; +use crate::capabilities::{ + detect_variant, display_model, probe_capabilities, verify_identity, Capabilities, +}; /// Minimum interval between reconnection attempts to avoid hammering offline miners. const RECONNECT_BACKOFF: Duration = Duration::from_secs(30); @@ -27,6 +34,14 @@ const IDENTITY_TIMEOUT: Duration = Duration::from_secs(10); const TELEMETRY_TIMEOUT: Duration = Duration::from_secs(15); /// Timeout for individual miner control/query operations. const OP_TIMEOUT: Duration = Duration::from_secs(20); +/// Timeout for firmware upload/apply operations. Matches the legacy Go plugin. +const FIRMWARE_UPDATE_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Match the server-side firmware upload limit. +const MAX_FIRMWARE_FILE_SIZE_BYTES: u64 = 500 * 1024 * 1024; +/// Keep plugin log responses below common gRPC message-size limits. +const MAX_LOG_RESPONSE_BYTES: usize = 512 * 1024; +/// Bound retained continuation batches so a sequence of log downloads cannot grow forever. +const MAX_LOG_BATCHES: usize = 16; /// Timeout for write validation probe. const WRITE_PROBE_TIMEOUT: Duration = Duration::from_secs(10); /// Shorter timeout for the MiningMode attempt in set_power_target. asic-rs internally @@ -56,7 +71,8 @@ pub struct AsicRsDevice { pre_full_curtail_mining: Mutex, last_connect_attempt: Mutex>, factory: Arc, - auth: Option, + auth: Mutex>, + log_batches: Mutex>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -107,7 +123,8 @@ impl AsicRsDevice { pre_full_curtail_mining: Mutex::new(FullCurtailMiningState::Unknown), last_connect_attempt: Mutex::new(None), factory, - auth, + auth: Mutex::new(auth), + log_batches: Mutex::new(HashMap::new()), } } @@ -147,8 +164,10 @@ impl AsicRsDevice { { *self.caps.lock().await = probe_capabilities(miner.as_ref()); *self.probed.lock().await = true; - if !data.device_info.model.is_empty() { - *self.model.lock().await = data.device_info.model.clone(); + let variant = detect_variant(&data.device_info.make, &data.device_info.firmware); + let model = display_model(&data.device_info.make, variant, &data.device_info.model); + if !model.is_empty() { + *self.model.lock().await = model; } } } @@ -202,7 +221,8 @@ impl AsicRsDevice { // Apply auth BEFORE identity check -- miners that require auth for read // operations (e.g. get_data) will fail the identity probe without credentials. - if let Some(ref auth) = self.auth { + let auth = self.auth.lock().await.clone(); + if let Some(ref auth) = auth { m.set_auth(auth.clone()); } @@ -219,11 +239,16 @@ impl AsicRsDevice { let actual_serial = data.serial_number.as_deref().unwrap_or(""); let actual_mac = data.mac.as_ref().map(|m| m.to_string()).unwrap_or_default(); + let variant = + detect_variant(&data.device_info.make, &data.device_info.firmware); + let actual_model = + display_model(&data.device_info.make, variant, &data.device_info.model); + verify_identity( &self.info.model, &self.info.serial_number, &self.info.mac_address, - &data.device_info.model, + &actual_model, actual_serial, &actual_mac, ) @@ -235,8 +260,8 @@ impl AsicRsDevice { // Refresh capabilities and model from the live miner instance *self.caps.lock().await = probe_capabilities(m.as_ref()); *self.probed.lock().await = true; - if !data.device_info.model.is_empty() { - *self.model.lock().await = data.device_info.model.clone(); + if !actual_model.is_empty() { + *self.model.lock().await = actual_model; } } Ok(Err(_)) => { @@ -261,8 +286,10 @@ impl AsicRsDevice { { *self.caps.lock().await = probe_capabilities(m.as_ref()); *self.probed.lock().await = true; - if !data.device_info.model.is_empty() { - *self.model.lock().await = data.device_info.model.clone(); + let variant = detect_variant(&data.device_info.make, &data.device_info.firmware); + let model = display_model(&data.device_info.make, variant, &data.device_info.model); + if !model.is_empty() { + *self.model.lock().await = model; } } } @@ -773,6 +800,254 @@ impl AsicRsDevice { Ok(()) } + pub async fn download_logs( + &self, + since: Option, + batch_log_uuid: &str, + ) -> anyhow::Result<(String, bool)> { + if !batch_log_uuid.is_empty() { + let mut batches = self.log_batches.lock().await; + if let Some(remaining) = batches.remove(batch_log_uuid) { + let (chunk, next) = take_log_chunk(remaining); + if let Some(next) = next { + batches.insert(batch_log_uuid.to_string(), next); + return Ok((chunk, true)); + } + return Ok((chunk, false)); + } + } + + let guard = self.connected_miner().await?; + self.require_cap(CAP_LOGS_DOWNLOAD).await?; + let miner = guard.as_ref().unwrap(); + + let result = catch_panic(tokio::time::timeout(OP_TIMEOUT, miner.read_logs())).await?; + let logs = result + .map_err(|_| anyhow::anyhow!("download_logs timed out"))? + .map_err(|e| anyhow::anyhow!("download_logs failed: {e}"))?; + let logs = filter_logs_since(logs, since)?; + let (chunk, next) = take_log_chunk(logs); + + if let Some(next) = next { + if batch_log_uuid.is_empty() { + return Err(anyhow::anyhow!( + "batch_log_uuid is required when logs exceed {MAX_LOG_RESPONSE_BYTES} bytes" + )); + } + let mut batches = self.log_batches.lock().await; + if batches.len() >= MAX_LOG_BATCHES { + batches.clear(); + } + batches.insert(batch_log_uuid.to_string(), next); + Ok((chunk, true)) + } else { + Ok((chunk, false)) + } + } + + pub async fn update_miner_password( + &self, + current_password: &str, + new_password: &str, + ) -> anyhow::Result<()> { + let mut guard = self.connected_miner().await?; + self.require_cap(CAP_UPDATE_MINER_PASSWORD).await?; + let miner = guard.as_mut().unwrap(); + + let original_auth = + self.auth.lock().await.clone().ok_or_else(|| { + anyhow::anyhow!("stored credentials are required to update password") + })?; + let current_auth = MinerAuth::new(original_auth.username.clone(), current_password); + miner.set_auth(current_auth); + + let result = catch_panic(tokio::time::timeout( + OP_TIMEOUT, + miner.change_password(new_password), + )) + .await?; + let ok = match result + .map_err(|_| anyhow::anyhow!("update_miner_password timed out"))? + .map_err(|e| anyhow::anyhow!("update_miner_password failed: {e}")) + { + Ok(ok) => ok, + Err(err) => { + miner.set_auth(original_auth); + return Err(err); + } + }; + if !ok { + miner.set_auth(original_auth); + return Err(anyhow::anyhow!( + "update_miner_password command returned false" + )); + } + + let new_auth = MinerAuth::new(original_auth.username, new_password); + miner.set_auth(new_auth.clone()); + *self.auth.lock().await = Some(new_auth); + + drop(guard); + self.invalidate_cache().await; + Ok(()) + } + + pub async fn update_firmware(&self, firmware: pb::FirmwareFileInfo) -> anyhow::Result<()> { + if firmware.file_path.is_empty() { + return Err(anyhow::anyhow!("firmware file path is required")); + } + if firmware.file_size < 0 { + return Err(anyhow::anyhow!("firmware file size must not be negative")); + } + if firmware.file_size as u64 > MAX_FIRMWARE_FILE_SIZE_BYTES { + return Err(anyhow::anyhow!( + "firmware file size {} exceeds maximum {}", + firmware.file_size, + MAX_FIRMWARE_FILE_SIZE_BYTES + )); + } + let metadata = tokio::fs::metadata(&firmware.file_path) + .await + .map_err(|e| anyhow::anyhow!("failed to stat firmware file: {e}"))?; + if metadata.len() > MAX_FIRMWARE_FILE_SIZE_BYTES { + return Err(anyhow::anyhow!( + "firmware file size {} exceeds maximum {}", + metadata.len(), + MAX_FIRMWARE_FILE_SIZE_BYTES + )); + } + + self.ensure_connected().await?; + self.require_cap(CAP_MANUAL_UPLOAD).await?; + + let mut image = FirmwareImage::from_file_async(&firmware.file_path) + .await + .map_err(|e| anyhow::anyhow!("failed to read firmware file: {e}"))?; + if !firmware.original_filename.is_empty() { + image.filename = firmware.original_filename; + } + + if self.is_stock_antminer_device() { + let ok = self.update_stock_antminer_firmware(image).await?; + if !ok { + return Err(anyhow::anyhow!("update_firmware command returned false")); + } + self.invalidate_connection().await; + return Ok(()); + } + + let guard = self.connected_miner().await?; + self.require_cap(CAP_MANUAL_UPLOAD).await?; + let miner = guard.as_ref().unwrap(); + let result = catch_panic(tokio::time::timeout( + FIRMWARE_UPDATE_TIMEOUT, + miner.upgrade_firmware(image), + )) + .await?; + let ok = match result { + Err(_) => return Err(anyhow::anyhow!("update_firmware timed out")), + Ok(Ok(ok)) => ok, + Ok(Err(e)) => return Err(anyhow::anyhow!("update_firmware failed: {e}")), + }; + if !ok { + return Err(anyhow::anyhow!("update_firmware command returned false")); + } + + drop(guard); + self.invalidate_connection().await; + Ok(()) + } + + fn is_stock_antminer_device(&self) -> bool { + self.info.manufacturer.eq_ignore_ascii_case("bitmain") + || self.info.manufacturer.eq_ignore_ascii_case("antminer") + } + + async fn update_stock_antminer_firmware(&self, image: FirmwareImage) -> anyhow::Result { + let auth = self + .auth + .lock() + .await + .clone() + .ok_or_else(|| anyhow::anyhow!("stock Antminer firmware fallback requires auth"))?; + + let host = &self.info.host; + let client = reqwest::Client::builder() + .timeout(FIRMWARE_UPDATE_TIMEOUT) + .build() + .map_err(|e| anyhow::anyhow!("failed to create firmware upload client: {e}"))?; + + let challenge_url = format!("http://{host}:80/cgi-bin/get_system_info.cgi"); + let challenge = client + .get(&challenge_url) + .timeout(OP_TIMEOUT) + .send() + .await + .map_err(|e| anyhow::anyhow!("firmware auth challenge failed: {e}"))?; + let www_authenticate = challenge + .headers() + .get("www-authenticate") + .ok_or_else(|| anyhow::anyhow!("firmware auth challenge missing WWW-Authenticate"))? + .to_str() + .map_err(|e| anyhow::anyhow!("invalid firmware auth challenge header: {e}"))?; + + let upload_path = "/cgi-bin/upgrade.cgi"; + let mut prompt = digest_auth::parse(www_authenticate) + .map_err(|e| anyhow::anyhow!("invalid firmware auth challenge: {e}"))?; + let context = AuthContext::new_with_method( + &auth.username, + auth.password.expose_secret(), + upload_path, + None::<&[u8]>, + HttpMethod::POST, + ); + let authorization = prompt + .respond(&context) + .map_err(|e| anyhow::anyhow!("failed to build firmware auth header: {e}"))?; + + let FirmwareImage { filename, bytes } = image; + let part = reqwest::multipart::Part::bytes(bytes) + .file_name(filename) + .mime_str("application/octet-stream") + .map_err(|e| anyhow::anyhow!("failed to build firmware upload part: {e}"))?; + let form = reqwest::multipart::Form::new().part("firmware", part); + + let upload_url = format!("http://{host}:80{upload_path}"); + let response = client + .post(upload_url) + .multipart(form) + .header(AUTHORIZATION, authorization.to_header_string()) + .timeout(FIRMWARE_UPDATE_TIMEOUT) + .send() + .await + .map_err(|e| anyhow::anyhow!("firmware upload HTTP request failed: {e}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| anyhow::anyhow!("failed to read firmware upload response body: {e}"))?; + if !status.is_success() { + return Err(anyhow::anyhow!( + "firmware upload failed with status code {status}: {body}" + )); + } + + let parsed: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| anyhow::anyhow!("invalid firmware upload response: {e}"))?; + let ok = parsed.get("code").and_then(|v| v.as_str()) == Some("U000") + && parsed.get("stats").and_then(|v| v.as_str()) == Some("success"); + if !ok { + let message = parsed + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(anyhow::anyhow!("firmware upload rejected: {message}")); + } + + Ok(true) + } + pub async fn set_power_target(&self, mode: pb::PerformanceMode) -> anyhow::Result<()> { let guard = self.connected_miner().await?; self.require_cap(CAP_POWER_MODE_EFFICIENCY).await?; @@ -1132,6 +1407,70 @@ fn classify_error(msg: &str) -> (pb::MinerError, pb::Severity, pb::ComponentType (miner_error, severity, component_type) } +fn take_log_chunk(mut logs: String) -> (String, Option) { + if logs.len() <= MAX_LOG_RESPONSE_BYTES { + return (logs, None); + } + + let mut split_at = MAX_LOG_RESPONSE_BYTES; + while split_at > 0 && !logs.is_char_boundary(split_at) { + split_at -= 1; + } + if split_at == 0 { + split_at = logs + .char_indices() + .nth(1) + .map(|(idx, _)| idx) + .unwrap_or(logs.len()); + } + + let remaining = logs.split_off(split_at); + (logs, Some(remaining)) +} + +fn filter_logs_since( + logs: String, + since: Option, +) -> anyhow::Result { + let Some(since) = since else { + return Ok(logs); + }; + if since.nanos < 0 || since.nanos >= 1_000_000_000 { + return Err(anyhow::anyhow!("invalid since timestamp nanos")); + } + let Some(since) = DateTime::::from_timestamp(since.seconds, since.nanos as u32) else { + return Err(anyhow::anyhow!("invalid since timestamp")); + }; + let since_prefix = since.format("%Y-%m-%d %H:%M:%S").to_string(); + + Ok(logs + .lines() + .filter(|line| { + let Some(prefix) = line.get(..19) else { + return true; + }; + !is_log_timestamp_prefix(prefix) || prefix >= since_prefix.as_str() + }) + .collect::>() + .join("\n")) +} + +fn is_log_timestamp_prefix(prefix: &str) -> bool { + let bytes = prefix.as_bytes(); + bytes.len() == 19 + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[4] == b'-' + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[7] == b'-' + && bytes[8..10].iter().all(u8::is_ascii_digit) + && bytes[10] == b' ' + && bytes[11..13].iter().all(u8::is_ascii_digit) + && bytes[13] == b':' + && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[16] == b':' + && bytes[17..19].iter().all(u8::is_ascii_digit) +} + #[cfg(test)] mod tests { use super::*; @@ -1193,7 +1532,6 @@ mod tests { firmware: String::new(), algo: asic_rs_core::data::device::HashAlgorithm::SHA256, hardware: asic_rs_core::data::device::MinerHardware { - chips: None, fans: None, boards: None, }, @@ -1219,6 +1557,7 @@ mod tests { pools: vec![], messages: vec![], tuning_target: None, + scaled_tuning_target: None, fluid_temperature: None, light_flashing: None, total_chips: None, @@ -1449,4 +1788,39 @@ mod tests { // Assert assert!(matches!(strategy, WriteAccessProbeStrategy::None)); } + + #[test] + fn test_filter_logs_since_keeps_lines_at_or_after_timestamp() { + let logs = [ + "2026-03-12 09:59:59 old", + "line without timestamp", + "2026-03-12 10:00:00 keep", + "2026-03-12 10:00:01 newer", + ] + .join("\n"); + + let filtered = filter_logs_since( + logs, + Some(prost_types::Timestamp { + seconds: 1_773_309_600, + nanos: 0, + }), + ) + .unwrap(); + + assert!(!filtered.contains("old")); + assert!(filtered.contains("line without timestamp")); + assert!(filtered.contains("keep")); + assert!(filtered.contains("newer")); + } + + #[test] + fn test_take_log_chunk_splits_on_utf8_boundary() { + let logs = format!("{}é{}", "a".repeat(MAX_LOG_RESPONSE_BYTES - 1), "b"); + + let (chunk, remaining) = take_log_chunk(logs); + + assert!(chunk.len() <= MAX_LOG_RESPONSE_BYTES); + assert_eq!(remaining.unwrap(), "éb"); + } } diff --git a/plugin/asicrs/src/driver.rs b/plugin/asicrs/src/driver.rs index 0a077c9dc7..4beaf64778 100644 --- a/plugin/asicrs/src/driver.rs +++ b/plugin/asicrs/src/driver.rs @@ -13,8 +13,9 @@ use proto_fleet_plugin::capabilities::CAP_NATIVE_STRATUM_V2; use proto_fleet_plugin::pb; use crate::capabilities::{ - default_credentials, detect_variant, driver_base_capabilities, firmware_manufacturer, - make_to_family, static_base_capabilities, verify_identity, VARIANT_STOCK, + default_credentials, detect_variant, display_manufacturer, display_model, + driver_base_capabilities, make_to_family, static_base_capabilities, verify_identity, + VARIANT_STOCK, }; use crate::config::{MinerFamilyConfig, PluginConfig}; use crate::device::AsicRsDevice; @@ -387,9 +388,8 @@ impl Driver for DriverService { ))); } - let manufacturer = firmware_manufacturer(variant) - .unwrap_or(make.as_str()) - .to_string(); + let manufacturer = display_manufacturer(&make, variant); + let model = display_model(&make, variant, &model); let url_scheme = "http"; @@ -509,7 +509,12 @@ impl Driver for DriverService { // Verify identity: compare fresh device data against the discovery record. // If the IP was reassigned between discovery and pairing, this catches it // before we persist the wrong identity into the fleet record. - let fresh_model = &data.device_info.model; + let fresh_variant = detect_variant(&data.device_info.make, &data.device_info.firmware); + let fresh_model = display_model( + &data.device_info.make, + fresh_variant, + &data.device_info.model, + ); let fresh_serial = data.serial_number.clone().unwrap_or_default(); let fresh_mac = data.mac.as_ref().map(|m| m.to_string()).unwrap_or_default(); @@ -517,7 +522,7 @@ impl Driver for DriverService { &device_info.model, &device_info.serial_number, &device_info.mac_address, - fresh_model, + &fresh_model, &fresh_serial, &fresh_mac, ) @@ -545,10 +550,7 @@ impl Driver for DriverService { // Derive canonical manufacturer from fresh firmware data, not stale discovery. // Aftermarket firmware (VNish, Braiins, LuxOS) gets reported as the firmware vendor. - let fresh_variant = detect_variant(&data.device_info.make, &data.device_info.firmware); - let fresh_manufacturer = firmware_manufacturer(fresh_variant) - .unwrap_or(data.device_info.make.as_str()) - .to_string(); + let fresh_manufacturer = display_manufacturer(&data.device_info.make, fresh_variant); tracing::info!( model = %fresh_model, @@ -565,7 +567,7 @@ impl Driver for DriverService { port: device_info.port, url_scheme: device_info.url_scheme, serial_number: fresh_serial, - model: fresh_model.clone(), + model: fresh_model, manufacturer: fresh_manufacturer, mac_address: fresh_mac, firmware_version, @@ -842,16 +844,44 @@ impl Driver for DriverService { async fn download_logs( &self, - _req: Request, + req: Request, ) -> Result, Status> { - Err(Status::unimplemented("download_logs not supported")) + let req = req.into_inner(); + let device_id = req + .r#ref + .as_ref() + .map(|r| &r.device_id) + .ok_or_else(|| Status::invalid_argument("Missing device ref"))?; + let device = self.get_device(device_id).await?; + let (log_data, more_data) = device + .download_logs(req.since, &req.batch_log_uuid) + .await + .map_err(device_err_to_status)?; + Ok(Response::new(pb::DownloadLogsResponse { + log_data, + more_data, + })) } async fn update_firmware( &self, - _req: Request, + req: Request, ) -> Result, Status> { - Err(Status::unimplemented("update_firmware not yet supported")) + let req = req.into_inner(); + let device_id = req + .r#ref + .as_ref() + .map(|r| &r.device_id) + .ok_or_else(|| Status::invalid_argument("Missing device ref"))?; + let firmware = req + .firmware + .ok_or_else(|| Status::invalid_argument("Missing firmware file info"))?; + let device = self.get_device(device_id).await?; + device + .update_firmware(firmware) + .await + .map_err(device_err_to_status)?; + Ok(Response::new(())) } async fn get_firmware_update_status( @@ -870,9 +900,28 @@ impl Driver for DriverService { async fn update_miner_password( &self, - _req: Request, + req: Request, ) -> Result, Status> { - Err(Status::unimplemented("update_miner_password not supported")) + let req = req.into_inner(); + let device_id = req + .r#ref + .as_ref() + .map(|r| &r.device_id) + .ok_or_else(|| Status::invalid_argument("Missing device ref"))?; + if req.new_password.is_empty() { + return Err(Status::invalid_argument("new_password must not be empty")); + } + if req.current_password.is_empty() { + return Err(Status::invalid_argument( + "current_password must not be empty", + )); + } + let device = self.get_device(device_id).await?; + device + .update_miner_password(&req.current_password, &req.new_password) + .await + .map_err(device_err_to_status)?; + Ok(Response::new(())) } // --- Curtailment --- diff --git a/server/README.md b/server/README.md index ac030f4b28..b8e7f582a3 100644 --- a/server/README.md +++ b/server/README.md @@ -17,7 +17,7 @@ just rebuild-services # Clean rebuild of docker services only (reuses existing p just rebuild-fleet-api # Rebuild just fleet-api ``` -From the repo root, `just rebuild-plugin ` rebuilds a single plugin (`proto`, `antminer`, `virtual`, or `asicrs`) without touching the others. +From the repo root, `just rebuild-plugin ` rebuilds a single plugin (`proto`, `virtual`, or `asicrs`) without touching the others. ### Delve debugging diff --git a/server/cmd/fleetnode/README.md b/server/cmd/fleetnode/README.md index 13f0612445..986242c1cd 100644 --- a/server/cmd/fleetnode/README.md +++ b/server/cmd/fleetnode/README.md @@ -37,7 +37,6 @@ server/.fleetnode/ ├── nmap (symlink to system nmap, if present) └── plugins/ ├── proto-plugin - ├── antminer-plugin ├── virtual-plugin └── asicrs-plugin ``` diff --git a/server/cmd/fleetnode/control_test.go b/server/cmd/fleetnode/control_test.go index 56d080929c..3f04c32d25 100644 --- a/server/cmd/fleetnode/control_test.go +++ b/server/cmd/fleetnode/control_test.go @@ -52,10 +52,10 @@ func runControlLoopOnce(t *testing.T, cmd *RunCmd, fake *controlFakeGateway) { func TestControlLoop_AcksAndReports(t *testing.T) { happyDisc := &stubDiscoverer{probes: map[string]*pb.DiscoveredDeviceReport{ - "10.0.0.5|4028": {DeviceIdentifier: "auto:1", IpAddress: "10.0.0.5", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, - "2001:db8::1|4028": {DeviceIdentifier: "auto:v6", IpAddress: "2001:db8::1", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, - "192.168.1.4|4028": {DeviceIdentifier: "auto:r1", IpAddress: "192.168.1.4", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, - "192.168.1.5|4028": {DeviceIdentifier: "auto:r2", IpAddress: "192.168.1.5", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, + "10.0.0.5|4028": {DeviceIdentifier: "auto:1", IpAddress: "10.0.0.5", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, + "2001:db8::1|4028": {DeviceIdentifier: "auto:v6", IpAddress: "2001:db8::1", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, + "192.168.1.4|4028": {DeviceIdentifier: "auto:r1", IpAddress: "192.168.1.4", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, + "192.168.1.5|4028": {DeviceIdentifier: "auto:r2", IpAddress: "192.168.1.5", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, }} tooManyIPs := make([]string, maxIPsPerCommand+1) @@ -303,7 +303,7 @@ func TestControlLoop_PartialResultsSurviveScanDeadline(t *testing.T) { // Arrange: first IP fast, later IPs block past commandTimeout. disc := &delayingStubDiscoverer{ fast: map[string]*pb.DiscoveredDeviceReport{ - "10.0.0.4|4028": {DeviceIdentifier: "auto:fast", IpAddress: "10.0.0.4", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, + "10.0.0.4|4028": {DeviceIdentifier: "auto:fast", IpAddress: "10.0.0.4", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, }, blockingIPs: map[string]bool{ "10.0.0.5": true, @@ -678,8 +678,8 @@ func TestSynthesizeIdentifier(t *testing.T) { dev *discoverymodels.DiscoveredDevice want string }{ - {name: "mac wins", dev: dev("aa:bb:cc:dd:ee:ff", "SN1", "antminer", "S19"), want: "mac:aa:bb:cc:dd:ee:ff"}, - {name: "serial when no mac", dev: dev("", "SN1", "antminer", "S19"), want: "serial:SN1"}, + {name: "mac wins", dev: dev("aa:bb:cc:dd:ee:ff", "SN1", "asicrs", "S19"), want: "mac:aa:bb:cc:dd:ee:ff"}, + {name: "serial when no mac", dev: dev("", "SN1", "asicrs", "S19"), want: "serial:SN1"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -697,7 +697,7 @@ func TestSynthesizeIdentifier(t *testing.T) { t.Parallel() // Driver left mac/serial blank: identity comes from the trusted probed // endpoint passed in, so devices at different endpoints stay distinct. - blank := dev("", "", "antminer", "S19") + blank := dev("", "", "asicrs", "S19") // Act first := synthesizeIdentifier(blank, "10.0.0.7", "4028", 1) @@ -714,7 +714,7 @@ func TestSynthesizeIdentifier(t *testing.T) { // Two nodes on overlapping RFC1918 space probe the same endpoint for // distinct miners; the synthesized auto: key must differ by node so the // server's upsert guard doesn't silently drop the second node's device. - blank := dev("", "", "antminer", "S19") + blank := dev("", "", "asicrs", "S19") // Act node1 := synthesizeIdentifier(blank, "192.168.1.20", "80", 1) @@ -755,7 +755,7 @@ func TestReportFromDiscovered(t *testing.T) { IpAddress: "10.0.0.5", Port: "4028", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", Model: "S19", Manufacturer: "Bitmain", FirmwareVersion: "v1", @@ -765,7 +765,7 @@ func TestReportFromDiscovered(t *testing.T) { IpAddress: "10.0.0.5", Port: "4028", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", Model: "S19", Manufacturer: "Bitmain", FirmwareVersion: "v1", @@ -816,7 +816,7 @@ func (b *blockingDiscoverer) Probe(ctx context.Context, ip, _ string) (*pb.Disco } select { case <-release: - return &pb.DiscoveredDeviceReport{DeviceIdentifier: "auto:" + ip, IpAddress: ip, Port: "4028", UrlScheme: "http", DriverName: "antminer"}, nil + return &pb.DiscoveredDeviceReport{DeviceIdentifier: "auto:" + ip, IpAddress: ip, Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, nil case <-ctx.Done(): return nil, fmt.Errorf("blocking discoverer cancelled: %w", ctx.Err()) } @@ -926,7 +926,7 @@ func TestFanOutProbes_SupervisorReturnsPartialOnStuckPlugin(t *testing.T) { t.Cleanup(func() { close(stuck) }) probe := func(ctx context.Context, ip, _ string) (*pb.DiscoveredDeviceReport, error) { if ip == "10.0.0.1" { - return &pb.DiscoveredDeviceReport{DeviceIdentifier: "auto:fast", IpAddress: ip, Port: "4028", UrlScheme: "http", DriverName: "antminer"}, nil + return &pb.DiscoveredDeviceReport{DeviceIdentifier: "auto:fast", IpAddress: ip, Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, nil } <-stuck return nil, nil @@ -961,7 +961,7 @@ func TestFanOutProbes_DropsInvalidReportInsteadOfPoisoningBatch(t *testing.T) { IpAddress: ip, Port: "4028", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, nil case "10.0.0.2": return &pb.DiscoveredDeviceReport{ @@ -969,7 +969,7 @@ func TestFanOutProbes_DropsInvalidReportInsteadOfPoisoningBatch(t *testing.T) { IpAddress: ip, Port: "4028", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", Model: strings.Repeat("X", 300), // exceeds max_len=255 }, nil } @@ -1000,7 +1000,7 @@ func TestControlLoop_SupervisorTruncatedScanAcksPartial(t *testing.T) { disc := &ctxIgnoringDiscoverer{ stuck: stuck, fast: map[string]*pb.DiscoveredDeviceReport{ - "10.0.0.1|4028": {DeviceIdentifier: "auto:fast", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, + "10.0.0.1|4028": {DeviceIdentifier: "auto:fast", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, }, stuckIPs: map[string]bool{"10.0.0.2": true}, } @@ -1090,7 +1090,7 @@ func TestFanOutProbes_OverridesPluginSuppliedEndpoint(t *testing.T) { IpAddress: "203.0.113.99", // not what we scanned Port: "9999", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, nil } @@ -1157,7 +1157,7 @@ func TestControlLoop_DropsCommandWithInvalidCommandID(t *testing.T) { // Queue a valid follow-up so we can prove via its ack that the loop // kept running normally past the dropped command. disc := &stubDiscoverer{probes: map[string]*pb.DiscoveredDeviceReport{ - "10.0.0.1|4028": {DeviceIdentifier: "auto:1", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, + "10.0.0.1|4028": {DeviceIdentifier: "auto:1", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, }} cmd := &RunCmd{discoverer: disc} state := &bootstrap.State{FleetNodeID: 7} @@ -1195,8 +1195,8 @@ func TestControlLoop_ConcurrentAcksSerialize(t *testing.T) { // stream.Send. Using a fast (non-blocking) discoverer ensures the // worker actually completes and emits its ack. disc := &stubDiscoverer{probes: map[string]*pb.DiscoveredDeviceReport{ - "10.0.0.1|4028": {DeviceIdentifier: "auto:a", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, - "10.0.0.2|4028": {DeviceIdentifier: "auto:b", IpAddress: "10.0.0.2", Port: "4028", UrlScheme: "http", DriverName: "antminer"}, + "10.0.0.1|4028": {DeviceIdentifier: "auto:a", IpAddress: "10.0.0.1", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, + "10.0.0.2|4028": {DeviceIdentifier: "auto:b", IpAddress: "10.0.0.2", Port: "4028", UrlScheme: "http", DriverName: "asicrs"}, }} cmd := &RunCmd{discoverer: disc} state := &bootstrap.State{FleetNodeID: 7} diff --git a/server/cmd/fleetnode/orphan_reaper_test.go b/server/cmd/fleetnode/orphan_reaper_test.go index 64a67d62a7..3136e942f0 100644 --- a/server/cmd/fleetnode/orphan_reaper_test.go +++ b/server/cmd/fleetnode/orphan_reaper_test.go @@ -23,13 +23,13 @@ func TestReapOrphans_SkipsLivePluginsOfOtherAgents(t *testing.T) { "100 1 fleetnode run", "200 1 fleetnode run --state-dir /alt", "500 100 /plugins/proto-plugin", - "600 200 /plugins/antminer-plugin", + "600 200 /plugins/asicrs-plugin", "999 1 /plugins/leftover-plugin", "", }, "\n") allowed := []string{ "/plugins/proto-plugin", - "/plugins/antminer-plugin", + "/plugins/asicrs-plugin", "/plugins/leftover-plugin", } diff --git a/server/cmd/fleetnode/pair_test.go b/server/cmd/fleetnode/pair_test.go index e6c1a2b192..ae5aa93188 100644 --- a/server/cmd/fleetnode/pair_test.go +++ b/server/cmd/fleetnode/pair_test.go @@ -192,8 +192,8 @@ func TestControlLoop_PairAcksAndReportsResults(t *testing.T) { fake.queue(pairCmd(t, &pairingpb.FleetNodePairRequest{ Credentials: &pairingpb.Credentials{Username: "root", Password: &pw}, Targets: []*pairingpb.FleetNodePairTarget{ - {DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}, - {DeviceIdentifier: "mac:bb", IpAddress: "10.0.0.6", Port: "80", DriverName: "antminer"}, + {DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "asicrs"}, + {DeviceIdentifier: "mac:bb", IpAddress: "10.0.0.6", Port: "80", DriverName: "asicrs"}, }, })) @@ -226,7 +226,7 @@ func TestControlLoop_PairPartialPersistAcksPartial(t *testing.T) { fake := &controlFakeGateway{} fake.setBehavior(controlFakeBehavior{pairRejected: 1}) fake.queue(pairCmd(t, &pairingpb.FleetNodePairRequest{ - Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}}, + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "asicrs"}}, })) // Act @@ -246,7 +246,7 @@ func TestControlLoop_PairAgentIncapableWithoutPairer(t *testing.T) { cmd := &RunCmd{} fake := &controlFakeGateway{} fake.queue(pairCmd(t, &pairingpb.FleetNodePairRequest{ - Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}}, + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "asicrs"}}, })) // Act @@ -284,7 +284,7 @@ func TestControlLoop_PairReportFailureAcksReportFailed(t *testing.T) { fake := &controlFakeGateway{} fake.setBehavior(controlFakeBehavior{pairReportErr: connect.NewError(connect.CodeUnavailable, errors.New("upload boom"))}) fake.queue(pairCmd(t, &pairingpb.FleetNodePairRequest{ - Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}}, + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "asicrs"}}, })) // Act @@ -334,7 +334,7 @@ func TestHandlePairCommand_BusyWhileAbandonedWorkersStillRunning(t *testing.T) { acks := &recordingAcker{} target := func(id, ip string) *pairingpb.FleetNodePairRequest { return &pairingpb.FleetNodePairRequest{ - Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: id, IpAddress: ip, Port: "80", DriverName: "antminer"}}, + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: id, IpAddress: ip, Port: "80", DriverName: "asicrs"}}, } } @@ -378,8 +378,8 @@ func TestControlLoop_PairSupervisorTruncatedAcksPartial(t *testing.T) { fake := &controlFakeGateway{} fake.queueWithID("pair-1", pairCmd(t, &pairingpb.FleetNodePairRequest{ Targets: []*pairingpb.FleetNodePairTarget{ - {DeviceIdentifier: "mac:fast", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}, - {DeviceIdentifier: "mac:stuck", IpAddress: "10.0.0.6", Port: "80", DriverName: "antminer"}, + {DeviceIdentifier: "mac:fast", IpAddress: "10.0.0.5", Port: "80", DriverName: "asicrs"}, + {DeviceIdentifier: "mac:stuck", IpAddress: "10.0.0.6", Port: "80", DriverName: "asicrs"}, }, })) client := newControlClient(t, fake) diff --git a/server/e2e/README.md b/server/e2e/README.md index 4f8e15faeb..03e2d0db8a 100644 --- a/server/e2e/README.md +++ b/server/e2e/README.md @@ -31,12 +31,12 @@ flowchart TB fleet["fleet-api
(port 4000)"] db[("TimescaleDB
(port 5433)")] proto-plugin["proto-plugin
(gRPC)"] - antminer-plugin["antminer-plugin
(gRPC)"] + asicrs-plugin["asicrs-plugin
(gRPC)"] proto-sim["proto-sim
(port 8080)"] fleet <--> db fleet -- loads --> proto-plugin - fleet -- loads --> antminer-plugin + fleet -- loads --> asicrs-plugin proto-plugin -- discovers/pairs --> proto-sim end @@ -77,7 +77,7 @@ Validates that the docker-compose environment is properly configured and all ser │ │ │ 3. PluginBinariesCorrect │ │ ├─ proto-plugin binary exists │ -│ ├─ antminer-plugin binary exists │ +│ ├─ asicrs-plugin binary exists │ │ ├─ Binaries are ELF format (not Mach-O) │ │ └─ Binaries are ARM64 architecture (for Docker) │ │ │ diff --git a/server/e2e/plugin_integration_test.go b/server/e2e/plugin_integration_test.go index caab5fd572..e9f6f8b40a 100644 --- a/server/e2e/plugin_integration_test.go +++ b/server/e2e/plugin_integration_test.go @@ -112,7 +112,7 @@ func testFleetAPIHealth(t *testing.T, ctx context.Context) { // testPluginBinaries verifies plugin binaries are correct architecture (Linux ARM64 ELF) func testPluginBinaries(t *testing.T) { - plugins := []string{"../plugins/proto-plugin", "../plugins/antminer-plugin"} + plugins := []string{"../plugins/proto-plugin", "../plugins/asicrs-plugin"} for _, pluginPath := range plugins { // Check file exists diff --git a/server/internal/domain/command/capability_checker_test.go b/server/internal/domain/command/capability_checker_test.go index b22c3c60f9..ed819e1e5c 100644 --- a/server/internal/domain/command/capability_checker_test.go +++ b/server/internal/domain/command/capability_checker_test.go @@ -258,14 +258,14 @@ func TestCheckDeviceCapabilities(t *testing.T) { t.Run("returns none supported when no devices support the command", func(t *testing.T) { provider := &mockCapabilitiesProvider{ capabilities: map[string]*capabilitiespb.MinerCapabilities{ - "antminer|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, + "asicrs|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, }, } checker := NewCapabilityChecker(nil, provider) devices := []deviceInfo{ - {DeviceIdentifier: "device-1", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, - {DeviceIdentifier: "device-2", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-1", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-2", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, } result := checker.checkDeviceCapabilities(ctx, devices, []string{sdk.CapabilityReboot}) @@ -285,16 +285,16 @@ func TestCheckDeviceCapabilities(t *testing.T) { t.Run("returns mixed results with partial support", func(t *testing.T) { provider := &mockCapabilitiesProvider{ capabilities: map[string]*capabilitiespb.MinerCapabilities{ - "proto|Proto|Model1": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: true}}, - "antminer|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, + "proto|Proto|Model1": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: true}}, + "asicrs|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, }, } checker := NewCapabilityChecker(nil, provider) devices := []deviceInfo{ {DeviceIdentifier: "device-1", DriverName: "proto", Manufacturer: "Proto", Model: "Model1", FirmwareVersion: "2.0.0"}, - {DeviceIdentifier: "device-2", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, - {DeviceIdentifier: "device-3", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-2", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-3", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, } result := checker.checkDeviceCapabilities(ctx, devices, []string{sdk.CapabilityReboot}) @@ -311,17 +311,17 @@ func TestCheckDeviceCapabilities(t *testing.T) { t.Run("groups unsupported devices by model and firmware version", func(t *testing.T) { provider := &mockCapabilitiesProvider{ capabilities: map[string]*capabilitiespb.MinerCapabilities{ - "antminer|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, - "antminer|Bitmain|S19Pro": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, + "asicrs|Bitmain|S19": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, + "asicrs|Bitmain|S19Pro": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, }, } checker := NewCapabilityChecker(nil, provider) devices := []deviceInfo{ - {DeviceIdentifier: "device-1", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, - {DeviceIdentifier: "device-2", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, - {DeviceIdentifier: "device-3", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19Pro", FirmwareVersion: "2.0.0"}, - {DeviceIdentifier: "device-4", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.1.0"}, + {DeviceIdentifier: "device-1", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-2", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-3", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19Pro", FirmwareVersion: "2.0.0"}, + {DeviceIdentifier: "device-4", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.1.0"}, } result := checker.checkDeviceCapabilities(ctx, devices, []string{sdk.CapabilityReboot}) @@ -343,13 +343,13 @@ func TestCheckDeviceCapabilities(t *testing.T) { t.Run("uses Unknown for empty model and firmware", func(t *testing.T) { provider := &mockCapabilitiesProvider{ capabilities: map[string]*capabilitiespb.MinerCapabilities{ - "antminer||": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, + "asicrs||": {Commands: &capabilitiespb.CommandCapabilities{RebootSupported: false}}, }, } checker := NewCapabilityChecker(nil, provider) devices := []deviceInfo{ - {DeviceIdentifier: "device-1", DriverName: "antminer", Manufacturer: "", Model: "", FirmwareVersion: ""}, + {DeviceIdentifier: "device-1", DriverName: "asicrs", Manufacturer: "", Model: "", FirmwareVersion: ""}, } result := checker.checkDeviceCapabilities(ctx, devices, []string{sdk.CapabilityReboot}) @@ -398,7 +398,7 @@ func TestCheckDeviceCapabilities(t *testing.T) { Commands: &capabilitiespb.CommandCapabilities{}, Firmware: &capabilitiespb.FirmwareCapabilities{ManualUploadSupported: true}, }, - "antminer|Bitmain|S19": { + "asicrs|Bitmain|S19": { Commands: &capabilitiespb.CommandCapabilities{}, Firmware: &capabilitiespb.FirmwareCapabilities{ManualUploadSupported: true}, }, @@ -412,7 +412,7 @@ func TestCheckDeviceCapabilities(t *testing.T) { devices := []deviceInfo{ {DeviceIdentifier: "device-1", DriverName: "proto", Manufacturer: "Proto", Model: "Rig1", FirmwareVersion: "2.0.0"}, - {DeviceIdentifier: "device-2", DriverName: "antminer", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, + {DeviceIdentifier: "device-2", DriverName: "asicrs", Manufacturer: "Bitmain", Model: "S19", FirmwareVersion: "1.0.0"}, {DeviceIdentifier: "device-3", DriverName: "virtual", Manufacturer: "Virtual", Model: "Miner", FirmwareVersion: "0.0.1"}, } diff --git a/server/internal/domain/command/execution_service_credentials_test.go b/server/internal/domain/command/execution_service_credentials_test.go index fe56c010dd..3c0d764bfc 100644 --- a/server/internal/domain/command/execution_service_credentials_test.go +++ b/server/internal/domain/command/execution_service_credentials_test.go @@ -81,7 +81,7 @@ func TestUpdateMinerPassword_DeviceTypeHandling(t *testing.T) { }{ { name: "Antminer devices store credentials in DB after update", - deviceType: "antminer", + deviceType: "asicrs", shouldStoreInDB: true, userProvidedPasswd: "currentpass", }, @@ -100,7 +100,7 @@ func TestUpdateMinerPassword_DeviceTypeHandling(t *testing.T) { // - Proto: credentials NOT stored (shouldStoreInDB = false, uses JWT) // - All device types require user to provide current password - if tt.deviceType == "antminer" { + if tt.deviceType == "asicrs" { assert.True(t, tt.shouldStoreInDB, "Antminer credentials should be stored after update") } else if tt.deviceType == "proto" { assert.False(t, tt.shouldStoreInDB, "Proto credentials should not be stored") @@ -123,13 +123,13 @@ func TestUpdateMinerPassword_CurrentPasswordRequired(t *testing.T) { }{ { name: "Antminer with current password succeeds", - deviceType: "antminer", + deviceType: "asicrs", userProvidedPasswd: "currentpass", shouldSucceed: true, }, { name: "Antminer without current password fails", - deviceType: "antminer", + deviceType: "asicrs", userProvidedPasswd: "", shouldSucceed: false, }, @@ -178,7 +178,7 @@ func (m *mockMinerForPassword) UpdateMinerPassword(ctx context.Context, payload } // Implement MinerInfo interface -func (m *mockMinerForPassword) GetDriverName() string { return "antminer" } +func (m *mockMinerForPassword) GetDriverName() string { return "asicrs" } func (m *mockMinerForPassword) GetID() models.DeviceIdentifier { return "test-device" } func (m *mockMinerForPassword) GetOrgID() int64 { return 1 } func (m *mockMinerForPassword) GetSiteID() int64 { return 0 } diff --git a/server/internal/domain/curtailment/service_start_test.go b/server/internal/domain/curtailment/service_start_test.go index 5ccbf95244..f03fc5e4ad 100644 --- a/server/internal/domain/curtailment/service_start_test.go +++ b/server/internal/domain/curtailment/service_start_test.go @@ -625,7 +625,7 @@ func TestService_Start_EmitsCandidateExclusionMetrics(t *testing.T) { power := 6000.0 zeroHR := 0.0 now := time.Now() - driver := "antminer" + driver := "asicrs" store.candidatesByOrg[orgID] = []*models.Candidate{ minerWithEff("ok", 6000, 100, 40), { diff --git a/server/internal/domain/curtailment/service_test.go b/server/internal/domain/curtailment/service_test.go index cedb909bda..21ead5c8ea 100644 --- a/server/internal/domain/curtailment/service_test.go +++ b/server/internal/domain/curtailment/service_test.go @@ -458,7 +458,7 @@ func miner(id string, status, pairing string, powerW, hashRateHS float64) *model t := time.Now() pw := powerW hr := hashRateHS - driver := "antminer" + driver := "asicrs" return &models.Candidate{ DeviceIdentifier: id, DriverName: &driver, @@ -478,7 +478,7 @@ func minerWithEff(id string, powerW, hashRateHS, effJH float64) *models.Candidat func staleMiner(id string) *models.Candidate { // LatestMetricsAt nil → service treats as stale_telemetry. - driver := "antminer" + driver := "asicrs" return &models.Candidate{ DeviceIdentifier: id, DriverName: &driver, diff --git a/server/internal/domain/fleetmanagement/export_csv_service_test.go b/server/internal/domain/fleetmanagement/export_csv_service_test.go index 72cfe310cd..8a0cbeeed5 100644 --- a/server/internal/domain/fleetmanagement/export_csv_service_test.go +++ b/server/internal/domain/fleetmanagement/export_csv_service_test.go @@ -39,7 +39,7 @@ func TestService_ExportMinerListCsv_ShouldExportOnlyPairedMinersAndRespectFilter DeviceIdentifier: "unpaired-device-1", Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.100", Port: "4028", UrlScheme: "http", diff --git a/server/internal/domain/fleetmanagement/service_test.go b/server/internal/domain/fleetmanagement/service_test.go index dea8fbae55..e0a0abb633 100644 --- a/server/internal/domain/fleetmanagement/service_test.go +++ b/server/internal/domain/fleetmanagement/service_test.go @@ -52,7 +52,7 @@ func TestService_ListMinerStateSnapshots_ShouldReturnAllDevices(t *testing.T) { DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: fmt.Sprintf("192.168.1.%d", 100+i), Port: "4028", UrlScheme: "http", @@ -142,7 +142,7 @@ func TestService_ListMinerStateSnapshots_ShouldFilterByPairingStatus(t *testing. DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: fmt.Sprintf("192.168.1.%d", 100+i), Port: "4028", UrlScheme: "http", @@ -440,7 +440,7 @@ func TestService_ListMinerStateSnapshots_ShouldCombineMultipleFilters(t *testing DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.200", Port: "4028", UrlScheme: "http", @@ -742,7 +742,7 @@ func TestService_ListMinerStateSnapshots_ShouldPopulateCapabilitiesForUnpairedDe DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.100", Port: "4028", UrlScheme: "http", @@ -985,7 +985,7 @@ func TestService_ListMinerStateSnapshots_IncludesFirmwareVersion(t *testing.T) { DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "antminer", + DriverName: "asicrs", IpAddress: "192.168.1.101", Port: "4028", UrlScheme: "http", diff --git a/server/internal/domain/fleetmanagement/snapshot_site_internal_test.go b/server/internal/domain/fleetmanagement/snapshot_site_internal_test.go index 487926b270..7ec32e9189 100644 --- a/server/internal/domain/fleetmanagement/snapshot_site_internal_test.go +++ b/server/internal/domain/fleetmanagement/snapshot_site_internal_test.go @@ -24,14 +24,14 @@ func TestListMinerStateSnapshots_PopulatesSiteIDAndLabel(t *testing.T) { rows := []sqlc.ListMinerStateSnapshotsRow{ { DeviceIdentifier: "miner-a", - DriverName: "antminer", + DriverName: "asicrs", PairingStatus: "UNPAIRED", SiteID: sql.NullInt64{Int64: 7, Valid: true}, SiteLabel: "Site Alpha", }, { DeviceIdentifier: "miner-b", - DriverName: "antminer", + DriverName: "asicrs", PairingStatus: "UNPAIRED", // Site unset — snapshot.SiteId must remain nil and label empty. SiteID: sql.NullInt64{}, diff --git a/server/internal/domain/ipscanner/integration_test.go b/server/internal/domain/ipscanner/integration_test.go index d890708bed..35db20b7bc 100644 --- a/server/internal/domain/ipscanner/integration_test.go +++ b/server/internal/domain/ipscanner/integration_test.go @@ -67,7 +67,7 @@ func TestIPScannerService_RediscoverOfflineDeviceAtNewIP(t *testing.T) { Port: "50051", UrlScheme: "grpc", MacAddress: "AA:BB:CC:DD:EE:02", // Second device moved here - DriverName: "antminer", + DriverName: "asicrs", }, }, }, diff --git a/server/internal/domain/miner/cache_test.go b/server/internal/domain/miner/cache_test.go index 3989a32074..1f77543ea2 100644 --- a/server/internal/domain/miner/cache_test.go +++ b/server/internal/domain/miner/cache_test.go @@ -61,7 +61,7 @@ func TestMinerService_GetMinerFromDeviceIdentifier_CachesAfterFirstLookup(t *tes Return(sdk.NewDeviceResult{Device: mockSDKDevice}, nil). Times(1) - pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "antminer"} + pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "asicrs"} service := miner.NewMinerService(db, userStore, encryptService, filesService, tokenService, pluginMgr) // Act — first call: DB lookup + plugin NewDevice @@ -103,7 +103,7 @@ func TestMinerService_GetMiner_CachesAfterFirstLookup(t *testing.T) { Return(sdk.NewDeviceResult{Device: mockSDKDevice}, nil). Times(1) - pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "antminer"} + pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "asicrs"} service := miner.NewMinerService(db, userStore, encryptService, filesService, tokenService, pluginMgr) // Act — first call: DB lookup + plugin NewDevice @@ -145,7 +145,7 @@ func TestMinerService_InvalidateMiner_ForcesRefreshOnNextLookup(t *testing.T) { Return(sdk.NewDeviceResult{Device: mockSDKDevice}, nil). Times(1) - pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "antminer"} + pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "asicrs"} service := miner.NewMinerService(db, userStore, encryptService, filesService, tokenService, pluginMgr) // Populate the cache @@ -193,7 +193,7 @@ func TestMinerService_InvalidateMiner_ForcesRefreshForBothLookupPaths(t *testing Return(sdk.NewDeviceResult{Device: mockSDKDevice}, nil). Times(1) - pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "antminer"} + pluginMgr := &fakePluginManager{driver: mockDriver, driverName: "asicrs"} service := miner.NewMinerService(db, userStore, encryptService, filesService, tokenService, pluginMgr) // Populate the shared cache via GetMiner. diff --git a/server/internal/domain/miner/service_capabilities_test.go b/server/internal/domain/miner/service_capabilities_test.go index 83aa7120ce..0fb5e12a64 100644 --- a/server/internal/domain/miner/service_capabilities_test.go +++ b/server/internal/domain/miner/service_capabilities_test.go @@ -57,7 +57,7 @@ func TestEffectiveCapabilitiesForDeviceUsesDriverCapsWithModelOverrides(t *testi }, } - caps := service.effectiveCapabilitiesForDevice(t.Context(), "antminer", "Bitmain", "Antminer S21") + caps := service.effectiveCapabilitiesForDevice(t.Context(), "asicrs", "Bitmain", "Antminer S21") assert.Equal(t, "Bitmain", driver.seenManufacturer) assert.Equal(t, "Antminer S21", driver.seenModel) @@ -86,7 +86,7 @@ func TestEffectiveCapabilitiesForDeviceSkipsModelProviderWhenModelUnknown(t *tes }, } - caps := service.effectiveCapabilitiesForDevice(t.Context(), "antminer", "Bitmain", "") + caps := service.effectiveCapabilitiesForDevice(t.Context(), "asicrs", "Bitmain", "") assert.Equal(t, 0, driver.callCount) assert.True(t, caps[sdk.CapabilityCurtailFull]) diff --git a/server/internal/domain/miner/service_test.go b/server/internal/domain/miner/service_test.go index a31c6ff23a..49efbd28c6 100644 --- a/server/internal/domain/miner/service_test.go +++ b/server/internal/domain/miner/service_test.go @@ -142,7 +142,7 @@ func TestMinerService_GetMinerFromDeviceID_WithDifferentMinerTypes_ShouldReturnC tests := []struct { deviceType string }{ - {"antminer"}, + {"asicrs"}, {"proto"}, {"whatsminer"}, {"avalon"}, @@ -212,7 +212,7 @@ func TestMinerService_GetMinerFromDeviceID_WithUnpairedDevice_ShouldReturnError( queries := sqlc.New(testContext.DatabaseService.DB) - discoveredDeviceID := createDiscoveredDevice(t, testContext.DatabaseService.DB, "TestMiner", "TestCorp", "antminer") + discoveredDeviceID := createDiscoveredDevice(t, testContext.DatabaseService.DB, "TestMiner", "TestCorp", "asicrs") // Create device without pairing record dbDeviceID, err := queries.InsertDevice(t.Context(), sqlc.InsertDeviceParams{ @@ -260,7 +260,7 @@ func TestMinerService_GetMinerFromDeviceID_WithDeviceNeitherTokenNorCredentials_ queries := sqlc.New(testContext.DatabaseService.DB) - discoveredDeviceID := createDiscoveredDevice(t, testContext.DatabaseService.DB, "TestMiner", "TestCorp", "antminer") + discoveredDeviceID := createDiscoveredDevice(t, testContext.DatabaseService.DB, "TestMiner", "TestCorp", "asicrs") // Create device with pairing but no credentials or token dbDeviceID, err := queries.InsertDevice(t.Context(), sqlc.InsertDeviceParams{ diff --git a/server/internal/domain/miner/testhelpers_test.go b/server/internal/domain/miner/testhelpers_test.go index 0bff05a8aa..d6a35bb0ed 100644 --- a/server/internal/domain/miner/testhelpers_test.go +++ b/server/internal/domain/miner/testhelpers_test.go @@ -101,7 +101,7 @@ func createTestDevice(t *testing.T, db *sql.DB, deviceIdentifier string) int64 { orgID := int64(1) - discoveredDeviceID := createDiscoveredDevice(t, db, "TestMiner", "TestCorp", "antminer") + discoveredDeviceID := createDiscoveredDevice(t, db, "TestMiner", "TestCorp", "asicrs") queries := sqlc.New(db) diff --git a/server/internal/domain/pairing/service_internal_test.go b/server/internal/domain/pairing/service_internal_test.go index 2c2d293890..c1eaa4dd1f 100644 --- a/server/internal/domain/pairing/service_internal_test.go +++ b/server/internal/domain/pairing/service_internal_test.go @@ -44,7 +44,7 @@ func TestHandleAuthenticationRequiredPairing_PreservesExistingWorkerName(t *test IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", MacAddress: "AA:BB:CC:DD:EE:FF", }, OrgID: 1, diff --git a/server/internal/domain/pairing/service_test.go b/server/internal/domain/pairing/service_test.go index 6c4287ef3f..0db5e9a162 100644 --- a/server/internal/domain/pairing/service_test.go +++ b/server/internal/domain/pairing/service_test.go @@ -156,7 +156,7 @@ func TestDiscoverWithIPList(t *testing.T) { mockDiscoverer := &MockDiscoverer{} mockDevice1 := createMockDevice("192.168.1.10", "8080", "proto") - mockDevice2 := createMockDevice("192.168.1.11", "8080", "antminer") + mockDevice2 := createMockDevice("192.168.1.11", "8080", "asicrs") mockDiscoverer.On("Discover", mock.Anything, "192.168.1.10", "8080").Run(func(_ mock.Arguments) { defer discoverWg.Done() @@ -670,7 +670,7 @@ func TestDiscoverWithIPRange(t *testing.T) { mockDiscoverer := &MockDiscoverer{} mockDevice1 := createMockDevice("192.168.1.10", "8080", "proto") mockDevice2 := createMockDevice("192.168.1.11", "8080", "proto") - mockDevice3 := createMockDevice("192.168.1.12", "8080", "antminer") + mockDevice3 := createMockDevice("192.168.1.12", "8080", "asicrs") // Set up mock calls that signal completion through WaitGroup mockDiscoverer.On("Discover", mock.Anything, "192.168.1.10", "8080").Run(func(_ mock.Arguments) { @@ -723,7 +723,7 @@ func TestDiscoverWithIPRange(t *testing.T) { mockDiscoverer := &MockDiscoverer{} mockDevice1 := createMockDevice("192.168.1.10", "8080", "proto") mockDevice2 := createMockDevice("192.168.1.11", "8080", "proto") - mockDevice3 := createMockDevice("192.168.1.12", "8080", "antminer") + mockDevice3 := createMockDevice("192.168.1.12", "8080", "asicrs") mockDiscoverer.On("Discover", mock.Anything, "192.168.1.10", "8080").Run(func(_ mock.Arguments) { defer discoverWg.Done() @@ -895,7 +895,7 @@ func TestPairDevices(t *testing.T) { portStr := "80" mockDiscoverer := &MockDiscoverer{} - mockDevice := createMockDevice(host, portStr, "antminer") + mockDevice := createMockDevice(host, portStr, "asicrs") mockDiscoverer.On("Discover", mock.Anything, host, portStr).Return(mockDevice, nil) // Create mock pairer that returns credentials required error @@ -1604,7 +1604,7 @@ func TestPairDevices_AllDevices_WithAuthNeededFilter(t *testing.T) { portStr := "80" mockDiscoverer := &MockDiscoverer{} - mockDevice := createMockDevice(host, portStr, "antminer") + mockDevice := createMockDevice(host, portStr, "asicrs") mockDiscoverer.On("Discover", mock.Anything, host, portStr).Return(mockDevice, nil) // Create mock pairer that returns credentials required error (sets AUTHENTICATION_NEEDED) diff --git a/server/internal/domain/plugins/discoverer_test.go b/server/internal/domain/plugins/discoverer_test.go index 3ed19d70df..69cf5ffe88 100644 --- a/server/internal/domain/plugins/discoverer_test.go +++ b/server/internal/domain/plugins/discoverer_test.go @@ -41,7 +41,7 @@ func TestMultiTypeDiscoverer_Discover_NoDiscoveryCapablePlugins(t *testing.T) { // Add mock plugin without discovery capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, // Has pairing but not discovery }, @@ -123,7 +123,7 @@ func TestMultiTypeDiscoverer_Discover_Success(t *testing.T) { // Add mock plugin with discovery capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, @@ -139,7 +139,7 @@ func TestMultiTypeDiscoverer_Discover_Success(t *testing.T) { require.NoError(t, err) require.NotNil(t, device) - assert.Equal(t, "antminer", device.DriverName) + assert.Equal(t, "asicrs", device.DriverName) assert.Equal(t, "192.168.1.100", device.IpAddress) assert.Equal(t, "80", device.Port) assert.Equal(t, tc.deviceInfo.SerialNumber, device.SerialNumber) @@ -197,7 +197,7 @@ func TestMultiTypeDiscoverer_Discover_FirstPluginFails(t *testing.T) { // Add successful plugin successPlugin := &LoadedPlugin{ Name: "success-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: successDriver, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, @@ -238,7 +238,7 @@ func TestMultiTypeDiscoverer_Discover_AllPluginsFail(t *testing.T) { manager.plugins["failing-plugin-1"] = &LoadedPlugin{ Name: "failing-plugin-1", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: failingDriver1, Caps: sdk.Capabilities{sdk.CapabilityDiscovery: true}, } @@ -308,7 +308,7 @@ func TestMultiTypeDiscoverer_Discover_ParallelExecution_FastPluginWins(t *testin manager.plugins["fast-plugin"] = &LoadedPlugin{ Name: "fast-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: fastDriver, Caps: sdk.Capabilities{sdk.CapabilityDiscovery: true}, } @@ -355,7 +355,7 @@ func TestMultiTypeDiscoverer_Discover_ContextCancellation(t *testing.T) { manager.plugins["blocking-plugin"] = &LoadedPlugin{ Name: "blocking-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: blockingDriver, Caps: sdk.Capabilities{sdk.CapabilityDiscovery: true}, } diff --git a/server/internal/domain/plugins/manager_test.go b/server/internal/domain/plugins/manager_test.go index d33cfa7b8d..26e2cea727 100644 --- a/server/internal/domain/plugins/manager_test.go +++ b/server/internal/domain/plugins/manager_test.go @@ -146,7 +146,7 @@ func TestManager_GetPluginByDriverName(t *testing.T) { manager := NewManager(&Config{}) // Act - plugin, exists := manager.GetPluginByDriverName("antminer") + plugin, exists := manager.GetPluginByDriverName("asicrs") // Assert assert.Nil(t, plugin) @@ -154,12 +154,12 @@ func TestManager_GetPluginByDriverName(t *testing.T) { // Arrange mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Act - plugin, exists = manager.GetPluginByDriverName("antminer") + plugin, exists = manager.GetPluginByDriverName("asicrs") // Assert assert.Equal(t, mockPlugin, plugin) @@ -170,16 +170,16 @@ func TestManager_HasPluginForDriverName(t *testing.T) { manager := NewManager(&Config{}) // Assert - assert.False(t, manager.HasPluginForDriverName("antminer")) + assert.False(t, manager.HasPluginForDriverName("asicrs")) // Arrange mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Assert - assert.True(t, manager.HasPluginForDriverName("antminer")) + assert.True(t, manager.HasPluginForDriverName("asicrs")) assert.False(t, manager.HasPluginForDriverName("whatsminer")) } diff --git a/server/internal/domain/plugins/pairer_test.go b/server/internal/domain/plugins/pairer_test.go index aff93c4ae6..bb2316361b 100644 --- a/server/internal/domain/plugins/pairer_test.go +++ b/server/internal/domain/plugins/pairer_test.go @@ -67,7 +67,7 @@ func TestPairer_PairDevice_NoPlugin(t *testing.T) { DeviceIdentifier: "test-device", IpAddress: "192.168.1.100", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, } credentials := &pb.Credentials{ @@ -91,12 +91,12 @@ func TestPairer_PairDevice_PluginNoPairingCapability(t *testing.T) { // Add mock plugin without pairing capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, // Has discovery but not pairing }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) @@ -105,7 +105,7 @@ func TestPairer_PairDevice_PluginNoPairingCapability(t *testing.T) { DeviceIdentifier: "test-device", IpAddress: "192.168.1.100", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, } credentials := &pb.Credentials{ @@ -155,13 +155,13 @@ func TestPairer_PairDevice_Success(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Create pairer with mocked dependencies transactor := mocks.NewMockTransactor(ctrl) @@ -186,7 +186,7 @@ func TestPairer_PairDevice_Success(t *testing.T) { Model: "S19", Manufacturer: "Bitmain", MacAddress: "00:11:22:33:44:55", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -342,12 +342,12 @@ func TestPairer_GetDeviceInfo_PluginNoPairingCapability(t *testing.T) { // Add mock plugin without pairing capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, // Has discovery but not pairing }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) @@ -356,7 +356,7 @@ func TestPairer_GetDeviceInfo_PluginNoPairingCapability(t *testing.T) { DeviceIdentifier: "test-device", IpAddress: "192.168.1.100", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -413,13 +413,13 @@ func TestPairer_GetDeviceInfo_Success(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Create pairer with mocked dependencies transactor := mocks.NewMockTransactor(ctrl) @@ -442,7 +442,7 @@ func TestPairer_GetDeviceInfo_Success(t *testing.T) { UrlScheme: "http", Model: "S19", Manufacturer: "Bitmain", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -464,7 +464,7 @@ func TestPairer_GetDeviceInfo_Success(t *testing.T) { assert.Equal(t, "TEST123", result.SerialNumber) assert.Equal(t, "S19 Pro", result.Model) assert.Equal(t, "Bitmain", result.Manufacturer) - assert.Equal(t, "antminer", result.DriverName) + assert.Equal(t, "asicrs", result.DriverName) assert.Equal(t, "00:11:22:33:44:55", result.MacAddress) } @@ -481,13 +481,13 @@ func TestPairer_GetDeviceInfo_DefaultPasswordActiveFromNewDevice_ReturnsForbidde mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ @@ -496,7 +496,7 @@ func TestPairer_GetDeviceInfo_DefaultPasswordActiveFromNewDevice_ReturnsForbidde IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -530,13 +530,13 @@ func TestPairer_GetDeviceInfo_DefaultPasswordActiveFromDescribeDevice_ReturnsFor mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ @@ -545,7 +545,7 @@ func TestPairer_GetDeviceInfo_DefaultPasswordActiveFromDescribeDevice_ReturnsFor IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -574,11 +574,11 @@ func TestPairer_GetDeviceInfo_GRPCUnauthenticatedFromNewDevice_ReturnsUnauthenti mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{sdk.CapabilityPairing: true}, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ @@ -587,7 +587,7 @@ func TestPairer_GetDeviceInfo_GRPCUnauthenticatedFromNewDevice_ReturnsUnauthenti IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -618,11 +618,11 @@ func TestPairer_GetDeviceInfo_GRPCUnauthenticatedFromDescribeDevice_ReturnsUnaut mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{sdk.CapabilityPairing: true}, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ @@ -631,7 +631,7 @@ func TestPairer_GetDeviceInfo_GRPCUnauthenticatedFromDescribeDevice_ReturnsUnaut IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -743,7 +743,7 @@ func TestPairer_FetchWorkerNameFromPairedDevice_UsesAnyConfiguredPoolWorkerNameA IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -774,14 +774,14 @@ func TestPairer_FetchWorkerNameFromPairedDevice_UsesAnyConfiguredPoolWorkerNameA plugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, sdk.CapabilityPoolConfig: true, }, } - manager.pluginsByDriverName["antminer"] = plugin + manager.pluginsByDriverName["asicrs"] = plugin workerName, err := pairer.fetchWorkerNameFromPairedDevice(t.Context(), plugin, discoveredDevice, credentials) @@ -808,7 +808,7 @@ func TestPairer_FetchWorkerNameFromPairedDevice_ClosesDeviceOnPoolReadError(t *t IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -836,14 +836,14 @@ func TestPairer_FetchWorkerNameFromPairedDevice_ClosesDeviceOnPoolReadError(t *t plugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, sdk.CapabilityPoolConfig: true, }, } - manager.pluginsByDriverName["antminer"] = plugin + manager.pluginsByDriverName["asicrs"] = plugin workerName, err := pairer.fetchWorkerNameFromPairedDevice(t.Context(), plugin, discoveredDevice, credentials) @@ -913,14 +913,14 @@ func TestPairer_PairDevice_AntminerAutoCredentials_Success(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: driverWithCreds, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Create pairer with mocked dependencies transactor := mocks.NewMockTransactor(ctrl) @@ -937,7 +937,7 @@ func TestPairer_PairDevice_AntminerAutoCredentials_Success(t *testing.T) { device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-001", + DeviceIdentifier: "asicrs-device-001", IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", @@ -945,7 +945,7 @@ func TestPairer_PairDevice_AntminerAutoCredentials_Success(t *testing.T) { Model: "S19", Manufacturer: "Bitmain", MacAddress: "00:11:22:33:44:55", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1021,14 +1021,14 @@ func TestPairer_PairDevice_AntminerAutoCredentials_PreservesPairingFirmwareWhenD } mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: driverWithCreds, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin transactor := mocks.NewMockTransactor(ctrl) discoveredDeviceStore := mocks.NewMockDiscoveredDeviceStore(ctrl) @@ -1044,7 +1044,7 @@ func TestPairer_PairDevice_AntminerAutoCredentials_PreservesPairingFirmwareWhenD device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-001", + DeviceIdentifier: "asicrs-device-001", IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", @@ -1052,7 +1052,7 @@ func TestPairer_PairDevice_AntminerAutoCredentials_PreservesPairingFirmwareWhenD Model: "S19", Manufacturer: "Bitmain", MacAddress: "00:11:22:33:44:55", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1098,7 +1098,7 @@ func TestPairer_PairDevice_AntminerAutoCredentials_AuthFailure(t *testing.T) { mockDriver := sdkMocks.NewMockDriver(ctrl) mockDriver.EXPECT(). PairDevice(gomock.Any(), gomock.Any(), gomock.Any()). - Return(sdk.DeviceInfo{}, sdk.NewErrorAuthenticationFailed("antminer-device-002")) + Return(sdk.DeviceInfo{}, sdk.NewErrorAuthenticationFailed("asicrs-device-002")) // Wrap mock driver with default credentials provider driverWithCreds := &mockDriverWithDefaultCredentials{ @@ -1110,23 +1110,23 @@ func TestPairer_PairDevice_AntminerAutoCredentials_AuthFailure(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: driverWithCreds, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-002", + DeviceIdentifier: "asicrs-device-002", IpAddress: "192.168.1.101", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1166,23 +1166,23 @@ func TestPairer_PairDevice_AntminerAutoCredentials_NetworkError(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: driverWithCreds, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-003", + DeviceIdentifier: "asicrs-device-003", IpAddress: "192.168.1.102", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1233,14 +1233,14 @@ func TestPairer_PairDevice_AntminerExplicitCredentials(t *testing.T) { // Add mock plugin with pairing capability mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Create pairer with mocked dependencies transactor := mocks.NewMockTransactor(ctrl) @@ -1257,11 +1257,11 @@ func TestPairer_PairDevice_AntminerExplicitCredentials(t *testing.T) { device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-004", + DeviceIdentifier: "asicrs-device-004", IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1312,21 +1312,21 @@ func TestPairer_PairDevice_PermissionDeniedFromPlugin_ReturnsForbidden(t *testin Return(sdk.DeviceInfo{}, status.Error(codes.PermissionDenied, "default password must be changed")) mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{sdk.CapabilityPairing: true}, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-locked", + DeviceIdentifier: "asicrs-device-locked", IpAddress: "192.168.1.150", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1350,21 +1350,21 @@ func TestPairer_PairDevice_GRPCUnauthenticatedFromPlugin_ReturnsUnauthenticated( Return(sdk.DeviceInfo{}, status.Error(codes.Unauthenticated, "authentication failed")) mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{sdk.CapabilityPairing: true}, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-bad-creds", + DeviceIdentifier: "asicrs-device-bad-creds", IpAddress: "192.168.1.151", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1396,20 +1396,20 @@ func TestPairer_PairDevice_DefaultPasswordViaAutoCredentials_ReturnsForbidden(t } mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: driverWithCreds, Caps: sdk.Capabilities{sdk.CapabilityPairing: true}, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) device := &discoverymodels.DiscoveredDevice{ Device: pb.Device{ - DeviceIdentifier: "antminer-device-default-pw", + DeviceIdentifier: "asicrs-device-default-pw", IpAddress: "192.168.1.152", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } @@ -1426,9 +1426,9 @@ func TestPairer_HandlePairViaStore_ReconcilesAuthRetryBySerial(t *testing.T) { manager := NewManager(&Config{}) mockDriver := sdkMocks.NewMockDriver(ctrl) - manager.pluginsByDriverName["antminer"] = &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + manager.pluginsByDriverName["asicrs"] = &LoadedPlugin{ + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, @@ -1453,7 +1453,7 @@ func TestPairer_HandlePairViaStore_ReconcilesAuthRetryBySerial(t *testing.T) { IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", MacAddress: "AA:BB:CC:DD:EE:FF", SerialNumber: "SN-001", }, @@ -1502,7 +1502,7 @@ func TestPairer_HandlePairViaStore_ReconcilesAuthRetryBySerial(t *testing.T) { IpAddress: "192.168.1.10", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, }, nil) @@ -1550,7 +1550,7 @@ func TestPairer_HandlePairViaStore_ReconcilesAuthRetryBySerial(t *testing.T) { UpsertDeviceStatus(gomock.Any(), models.DeviceIdentifier("paired-id"), models.MinerStatusActive, ""). Return(nil) - err = pairer.handlePairViaStore(ctx, device, credentials, "worker-01", false, manager.pluginsByDriverName["antminer"]) + err = pairer.handlePairViaStore(ctx, device, credentials, "worker-01", false, manager.pluginsByDriverName["asicrs"]) require.NoError(t, err) require.Equal(t, "paired-id", device.DeviceIdentifier) } @@ -1570,9 +1570,9 @@ func TestPairer_HandlePairViaStore_PreservesExistingWorkerNameOnFallback(t *test }) require.NoError(t, err) - manager.pluginsByDriverName["antminer"] = &LoadedPlugin{ - Name: "antminer-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + manager.pluginsByDriverName["asicrs"] = &LoadedPlugin{ + Name: "asicrs-plugin", + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, @@ -1586,7 +1586,7 @@ func TestPairer_HandlePairViaStore_PreservesExistingWorkerNameOnFallback(t *test IpAddress: "192.168.1.100", Port: "80", UrlScheme: "http", - DriverName: "antminer", + DriverName: "asicrs", MacAddress: "AA:BB:CC:DD:EE:FF", }, OrgID: 1, @@ -1631,7 +1631,7 @@ func TestPairer_HandlePairViaStore_PreservesExistingWorkerNameOnFallback(t *test UpsertDeviceStatus(gomock.Any(), models.DeviceIdentifier("device-123"), models.MinerStatusActive, ""). Return(nil) - err = pairer.handlePairViaStore(ctx, device, credentials, "AA:BB:CC:DD:EE:FF", true, manager.pluginsByDriverName["antminer"]) + err = pairer.handlePairViaStore(ctx, device, credentials, "AA:BB:CC:DD:EE:FF", true, manager.pluginsByDriverName["asicrs"]) require.NoError(t, err) } @@ -1702,13 +1702,13 @@ func TestPairer_PairDevice_WithoutDefaultCredentialsProvider(t *testing.T) { // Plugin with a driver that does NOT implement DefaultCredentialsProvider mockPlugin := &LoadedPlugin{ Name: "test-plugin", - Identifier: sdk.DriverIdentifier{DriverName: "antminer"}, + Identifier: sdk.DriverIdentifier{DriverName: "asicrs"}, Driver: mockDriver, // Plain driver without DefaultCredentialsProvider Caps: sdk.Capabilities{ sdk.CapabilityPairing: true, }, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin pairer := createTestPairer(ctrl, manager) @@ -1717,7 +1717,7 @@ func TestPairer_PairDevice_WithoutDefaultCredentialsProvider(t *testing.T) { DeviceIdentifier: "test-device-001", IpAddress: "192.168.1.100", Port: "80", - DriverName: "antminer", + DriverName: "asicrs", }, OrgID: 1, } diff --git a/server/internal/domain/plugins/plugin_miner.go b/server/internal/domain/plugins/plugin_miner.go index 899c307c67..bcde778cea 100644 --- a/server/internal/domain/plugins/plugin_miner.go +++ b/server/internal/domain/plugins/plugin_miner.go @@ -29,6 +29,8 @@ import ( var _ interfaces.Miner = &PluginMiner{} var _ interfaces.MinerInfo = &PluginMiner{} +const maxDownloadLogPages = 128 + // PluginMiner wraps an SDK Device to implement the interfaces.Miner interface. // // Lifecycle Management: @@ -369,11 +371,21 @@ func (p *PluginMiner) BlinkLED(ctx context.Context) error { // DownloadLogs implements interfaces.Miner func (p *PluginMiner) DownloadLogs(ctx context.Context, batchLogUUID string) error { - logData, _, err := p.sdkDevice.DownloadLogs(ctx, nil, batchLogUUID) - if err != nil { - return fleeterror.NewInternalErrorf("failed to download logs: %v", err) + var logBuilder strings.Builder + for page := 0; ; page++ { + logData, moreData, err := p.sdkDevice.DownloadLogs(ctx, nil, batchLogUUID) + if err != nil { + return fleeterror.NewInternalErrorf("failed to download logs: %v", err) + } + logBuilder.WriteString(logData) + if !moreData { + break + } + if page+1 >= maxDownloadLogPages { + return fleeterror.NewInternalErrorf("failed to download logs: exceeded %d pages", maxDownloadLogPages) + } } - logLines := strings.Split(strings.TrimRight(logData, "\n"), "\n") + logLines := strings.Split(strings.TrimRight(logBuilder.String(), "\n"), "\n") csvRows := formatLogsToCSV(logLines, p.caps[sdk.CapabilityAsymmetricAuth]) if _, err := p.filesService.SaveLogs(batchLogUUID, p.deviceInfo.MacAddress, csvRows); err != nil { diff --git a/server/internal/domain/plugins/plugin_miner_test.go b/server/internal/domain/plugins/plugin_miner_test.go index 1fdbf78311..4a902ec710 100644 --- a/server/internal/domain/plugins/plugin_miner_test.go +++ b/server/internal/domain/plugins/plugin_miner_test.go @@ -270,7 +270,7 @@ func createTestPluginMinerWithDevice(device sdk.Device) *PluginMiner { testOrgID, int64(0), models.DeviceIdentifier("test-device-123"), - "antminer", + "asicrs", nil, "SN123456", *connInfo, @@ -472,6 +472,31 @@ func TestPluginMiner_DownloadLogs_TrailingNewlineTrimmed(t *testing.T) { assert.Equal(t, `"2026-01-01T00:00:01Z","line two"`, saver.savedLines[2]) } +func TestPluginMiner_DownloadLogs_DrainsPagedResponses(t *testing.T) { + pm, mockDevice := createTestPluginMiner() + saver := &mockLogSaver{} + pm.filesService = saver + + calls := 0 + mockDevice.downloadLogsFunc = func(_ context.Context, _ *time.Time, uuid string) (string, bool, error) { + require.Equal(t, "batch-uuid", uuid) + calls++ + if calls == 1 { + return "[2026-01-01T00:00:00Z] line one\n", true, nil + } + return "[2026-01-01T00:00:01Z] line two\n", false, nil + } + + err := pm.DownloadLogs(context.Background(), "batch-uuid") + + require.NoError(t, err) + require.Equal(t, 2, calls) + require.Len(t, saver.savedLines, 3) + assert.Equal(t, "Time,Message", saver.savedLines[0]) + assert.Equal(t, `"2026-01-01T00:00:00Z","line one"`, saver.savedLines[1]) + assert.Equal(t, `"2026-01-01T00:00:01Z","line two"`, saver.savedLines[2]) +} + func TestPluginMiner_DownloadLogs_SDKError(t *testing.T) { pm, mockDevice := createTestPluginMiner() pm.filesService = &mockLogSaver{} @@ -750,7 +775,7 @@ func TestPluginMiner_MinerInfo(t *testing.T) { pm, _ := createTestPluginMiner() assert.Equal(t, models.DeviceIdentifier("test-device-123"), pm.GetID()) - assert.Equal(t, "antminer", pm.GetDriverName()) + assert.Equal(t, "asicrs", pm.GetDriverName()) assert.Equal(t, "SN123456", pm.GetSerialNumber()) assert.NotNil(t, pm.GetConnectionInfo()) } diff --git a/server/internal/domain/plugins/service_test.go b/server/internal/domain/plugins/service_test.go index 95dfc57451..102b5b7094 100644 --- a/server/internal/domain/plugins/service_test.go +++ b/server/internal/domain/plugins/service_test.go @@ -46,16 +46,16 @@ func TestService_IsPluginAvailableByDriverName(t *testing.T) { service := createTestServiceForServiceTest(t, ctrl, manager) // Act + Assert - no plugins registered - assert.False(t, service.GetManager().HasPluginForDriverName("antminer")) + assert.False(t, service.GetManager().HasPluginForDriverName("asicrs")) // Arrange mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Act + Assert - assert.True(t, service.GetManager().HasPluginForDriverName("antminer")) + assert.True(t, service.GetManager().HasPluginForDriverName("asicrs")) assert.False(t, service.GetManager().HasPluginForDriverName("whatsminer")) } @@ -136,7 +136,7 @@ func TestService_GetPluginCapabilitiesByDriverName(t *testing.T) { service := createTestServiceForServiceTest(t, ctrl, manager) // Act + Assert - no plugin - caps, err := service.GetPluginCapabilitiesByDriverName("antminer") + caps, err := service.GetPluginCapabilitiesByDriverName("asicrs") assert.Nil(t, caps) require.Error(t, err) assert.Contains(t, err.Error(), "no plugin") @@ -147,13 +147,13 @@ func TestService_GetPluginCapabilitiesByDriverName(t *testing.T) { sdk.CapabilityPairing: true, } mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", Caps: mockCaps, } - manager.pluginsByDriverName["antminer"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin // Act + Assert - caps, err = service.GetPluginCapabilitiesByDriverName("antminer") + caps, err = service.GetPluginCapabilitiesByDriverName("asicrs") require.NoError(t, err) assert.Equal(t, mockCaps, caps) } @@ -175,10 +175,10 @@ func TestService_GetDefaultDiscoveryPorts_ReturnsAllAdvertisedPorts(t *testing.T }, DiscoveryPorts: []string{"443", "8080"}, } - manager.plugins["antminer-plugin"] = &LoadedPlugin{ - Name: "antminer-plugin", + manager.plugins["asicrs-plugin"] = &LoadedPlugin{ + Name: "asicrs-plugin", Identifier: sdk.DriverIdentifier{ - DriverName: "antminer", + DriverName: "asicrs", }, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, @@ -206,7 +206,7 @@ func TestService_GetDefaultDiscoveryPorts_ReturnsAllAdvertisedPorts(t *testing.T DiscoveryPorts: []string{"4028"}, } - assert.Equal(t, []string{"4028", "443", "8080"}, service.GetDefaultDiscoveryPorts(t.Context())) + assert.ElementsMatch(t, []string{"4028", "443", "8080"}, service.GetDefaultDiscoveryPorts(t.Context())) } func TestService_GetDiscoveryPorts(t *testing.T) { @@ -226,10 +226,10 @@ func TestService_GetDiscoveryPorts(t *testing.T) { }, DiscoveryPorts: []string{"443", "8080"}, } - manager.plugins["antminer-plugin"] = &LoadedPlugin{ - Name: "antminer-plugin", + manager.plugins["asicrs-plugin"] = &LoadedPlugin{ + Name: "asicrs-plugin", Identifier: sdk.DriverIdentifier{ - DriverName: "antminer", + DriverName: "asicrs", }, Caps: sdk.Capabilities{ sdk.CapabilityDiscovery: true, @@ -343,7 +343,7 @@ func TestService_GetMinerCapabilitiesForDevice_NoPluginForDriverName(t *testing. service := createTestServiceForServiceTest(t, ctrl, manager) device := &pairingpb.Device{ - DriverName: "antminer", + DriverName: "asicrs", Model: "Antminer S19", Manufacturer: "Bitmain", } @@ -392,14 +392,14 @@ func TestService_GetMinerCapabilitiesForDevice_AntminerSuccess(t *testing.T) { } mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", Caps: antminerCaps, } - manager.pluginsByDriverName["antminer"] = mockPlugin - manager.plugins["antminer-plugin"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin + manager.plugins["asicrs-plugin"] = mockPlugin device := &pairingpb.Device{ - DriverName: "antminer", + DriverName: "asicrs", Model: "Antminer S19", Manufacturer: "Bitmain", } @@ -508,15 +508,15 @@ func TestService_GetMinerCapabilitiesForDevice_WithModelCapabilitiesProvider(t * } mockPlugin := &LoadedPlugin{ - Name: "antminer-plugin", + Name: "asicrs-plugin", Caps: baseCaps, Driver: combined, } - manager.pluginsByDriverName["antminer"] = mockPlugin - manager.plugins["antminer-plugin"] = mockPlugin + manager.pluginsByDriverName["asicrs"] = mockPlugin + manager.plugins["asicrs-plugin"] = mockPlugin device := &pairingpb.Device{ - DriverName: "antminer", + DriverName: "asicrs", Model: "Antminer S21", Manufacturer: "Bitmain", } diff --git a/server/internal/domain/stores/sqlstores/discovered_device_migration_test.go b/server/internal/domain/stores/sqlstores/discovered_device_migration_test.go new file mode 100644 index 0000000000..5623bc6639 --- /dev/null +++ b/server/internal/domain/stores/sqlstores/discovered_device_migration_test.go @@ -0,0 +1,124 @@ +package sqlstores_test + +import ( + "io/fs" + "testing" + + "github.com/block/proto-fleet/server/generated/sqlc" + "github.com/block/proto-fleet/server/internal/testutil" + "github.com/block/proto-fleet/server/migrations" + "github.com/stretchr/testify/require" +) + +func TestMigration000087_MigratesPairedAntminerDiscoveredDevicesToAsicrs(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test in short mode") + } + + db := testutil.GetTestDB(t) + ctx := t.Context() + queries := sqlc.New(db) + + orgID, err := queries.CreateOrganization(ctx, sqlc.CreateOrganizationParams{ + OrgID: "migration-000087", + Name: "Migration 000087", + MinerAuthPrivateKey: "test-key", + }) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + INSERT INTO discovered_device + (org_id, device_identifier, model, manufacturer, driver_name, ip_address, port, url_scheme, is_active) + VALUES + ($1, 'stock-antminer', 'Antminer S19', 'Bitmain', 'antminer', '192.168.1.10', '4028', 'http', TRUE), + ($1, 'deleted-antminer', 'Antminer S19', 'Bitmain', 'antminer', '192.168.1.11', '4028', 'http', TRUE), + ($1, 'proto-miner', 'Proto Rig', 'Proto', 'proto', '192.168.1.12', '50051', 'grpc', TRUE) + `, orgID) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + INSERT INTO device (org_id, discovered_device_id, device_identifier, mac_address) + SELECT org_id, id, device_identifier, 'AA:BB:CC:DD:EE:01' + FROM discovered_device + WHERE org_id = $1 AND device_identifier = 'stock-antminer' + `, orgID) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + UPDATE discovered_device + SET deleted_at = NOW() + WHERE org_id = $1 AND device_identifier = 'deleted-antminer' + `, orgID) + require.NoError(t, err) + + upSQL := readMigration(t, "000087_migrate_antminer_to_asicrs.up.sql") + _, err = db.ExecContext(ctx, upSQL) + require.NoError(t, err) + + rows, err := db.QueryContext(ctx, ` + SELECT device_identifier, driver_name, port + FROM discovered_device + WHERE org_id = $1 + `, orgID) + require.NoError(t, err) + defer rows.Close() + + got := map[string]struct { + driverName string + port string + }{} + for rows.Next() { + var deviceIdentifier, driverName, port string + require.NoError(t, rows.Scan(&deviceIdentifier, &driverName, &port)) + got[deviceIdentifier] = struct { + driverName string + port string + }{driverName: driverName, port: port} + } + require.NoError(t, rows.Err()) + + require.Equal(t, "asicrs", got["stock-antminer"].driverName) + require.Equal(t, "80", got["stock-antminer"].port) + require.Equal(t, "antminer", got["deleted-antminer"].driverName) + require.Equal(t, "4028", got["deleted-antminer"].port) + require.Equal(t, "proto", got["proto-miner"].driverName) + require.Equal(t, "50051", got["proto-miner"].port) + + var pairedDriverName string + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT dd.driver_name + FROM device d + JOIN discovered_device dd ON dd.id = d.discovered_device_id + WHERE d.org_id = $1 AND d.device_identifier = 'stock-antminer' + `, orgID).Scan(&pairedDriverName)) + require.Equal(t, "asicrs", pairedDriverName) + + downSQL := readMigration(t, "000087_migrate_antminer_to_asicrs.down.sql") + _, err = db.ExecContext(ctx, downSQL) + require.NoError(t, err) + + var downDriverName, downPort string + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT driver_name, port + FROM discovered_device + WHERE org_id = $1 AND device_identifier = 'stock-antminer' + `, orgID).Scan(&downDriverName, &downPort)) + require.Equal(t, "antminer", downDriverName) + require.Equal(t, "4028", downPort) + + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT driver_name, port + FROM discovered_device + WHERE org_id = $1 AND device_identifier = 'proto-miner' + `, orgID).Scan(&downDriverName, &downPort)) + require.Equal(t, "proto", downDriverName) + require.Equal(t, "50051", downPort) +} + +func readMigration(t *testing.T, name string) string { + t.Helper() + + contents, err := fs.ReadFile(migrations.Migrations, name) + require.NoError(t, err) + return string(contents) +} diff --git a/server/internal/domain/stores/sqlstores/discovered_device_test.go b/server/internal/domain/stores/sqlstores/discovered_device_test.go index 28cdfe2b75..8e7aefeeaf 100644 --- a/server/internal/domain/stores/sqlstores/discovered_device_test.go +++ b/server/internal/domain/stores/sqlstores/discovered_device_test.go @@ -48,7 +48,7 @@ func TestSQLDiscoveredDeviceStore_Save_ShouldInsertNewDevice(t *testing.T) { DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.100", Port: "4028", UrlScheme: "http", @@ -65,7 +65,7 @@ func TestSQLDiscoveredDeviceStore_Save_ShouldInsertNewDevice(t *testing.T) { assert.Equal(t, deviceIdentifier, saved.DeviceIdentifier) assert.Equal(t, "S19 Pro", saved.Model) assert.Equal(t, "Bitmain", saved.Manufacturer) - assert.Equal(t, "ANTMINER", saved.DriverName) + assert.Equal(t, "asicrs", saved.DriverName) assert.Equal(t, "192.168.1.100", saved.IpAddress) assert.Equal(t, "4028", saved.Port) assert.Equal(t, "http", saved.UrlScheme) @@ -109,7 +109,7 @@ func TestSQLDiscoveredDeviceStore_Save_ShouldUpdateExistingDevice(t *testing.T) DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.100", Port: "4028", UrlScheme: "http", @@ -288,7 +288,7 @@ func TestSQLDiscoveredDeviceStore_GetDevice_ShouldReturnExistingDevice(t *testin DeviceIdentifier: deviceIdentifier, Model: "S21", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.200", Port: "4028", UrlScheme: "http", @@ -349,7 +349,7 @@ func TestSQLDiscoveredDeviceStore_Save_ShouldAllowSettingIsActiveToTrue(t *testi DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: "192.168.1.100", Port: "4028", UrlScheme: "http", @@ -438,7 +438,7 @@ func TestSQLDiscoveredDeviceStore_GetActiveUnpairedDevices_ShouldReturnUnpairedD DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: fmt.Sprintf("192.168.1.%d", 100+i), Port: "4028", UrlScheme: "http", @@ -498,7 +498,7 @@ func TestSQLDiscoveredDeviceStore_GetActiveUnpairedDevices_ShouldSupportPaginati DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: fmt.Sprintf("192.168.1.%d", 100+i), Port: "4028", UrlScheme: "http", @@ -570,7 +570,7 @@ func TestSQLDiscoveredDeviceStore_CountActiveUnpairedDevices_ShouldReturnCorrect DeviceIdentifier: deviceIdentifier, Model: "S19 Pro", Manufacturer: "Bitmain", - DriverName: "ANTMINER", + DriverName: "asicrs", IpAddress: fmt.Sprintf("192.168.1.%d", 100+i), Port: "4028", UrlScheme: "http", diff --git a/server/internal/domain/telemetry/broadcaster_metrics_test.go b/server/internal/domain/telemetry/broadcaster_metrics_test.go index c5e508b231..1c4c73034d 100644 --- a/server/internal/domain/telemetry/broadcaster_metrics_test.go +++ b/server/internal/domain/telemetry/broadcaster_metrics_test.go @@ -83,7 +83,7 @@ func TestObserverEmitsHashrateInTerahash(t *testing.T) { rec := &recordingEmitter{} obs := newMetricsObserver(rec) - obs.onDeviceMetrics(context.Background(), 7, 42, "antminer", "ant-1", modelsV2.DeviceMetrics{ + obs.onDeviceMetrics(context.Background(), 7, 42, "asicrs", "ant-1", modelsV2.DeviceMetrics{ DeviceIdentifier: "ant-1", HashrateHS: metricVal(110e12), // 110 TH/s Health: modelsV2.HealthHealthyActive, @@ -92,7 +92,7 @@ func TestObserverEmitsHashrateInTerahash(t *testing.T) { require.Len(t, rec.hashrate, 1) require.InDelta(t, 110.0, rec.hashrate[0].observedTHs, 1e-9) require.Equal(t, "ant-1", rec.hashrate[0].labels.DeviceID) - require.Equal(t, "antminer", rec.hashrate[0].labels.Driver) + require.Equal(t, "asicrs", rec.hashrate[0].labels.Driver) require.Equal(t, "7", rec.hashrate[0].labels.OrganizationID) require.Equal(t, "42", rec.hashrate[0].labels.SiteID) } @@ -192,7 +192,7 @@ func TestObserverPollResultIsClosedEnum(t *testing.T) { func TestObserverFallsBackToAggregatedTempWhenNoComponents(t *testing.T) { rec := &recordingEmitter{} obs := newMetricsObserver(rec) - obs.onDeviceMetrics(context.Background(), 1, 0, "antminer", "ant-2", modelsV2.DeviceMetrics{ + obs.onDeviceMetrics(context.Background(), 1, 0, "asicrs", "ant-2", modelsV2.DeviceMetrics{ DeviceIdentifier: "ant-2", TempC: metricVal(68.5), Health: modelsV2.HealthHealthyActive, @@ -210,8 +210,8 @@ func TestObserverHandlesAllKnownDriversInFixture(t *testing.T) { dm modelsV2.DeviceMetrics }{ { - name: "antminer", - driver: "antminer", + name: "asicrs", + driver: "asicrs", dm: modelsV2.DeviceMetrics{ DeviceIdentifier: "ant-1", HashrateHS: metricVal(120e12), @@ -302,7 +302,7 @@ func TestObserverDropsSampleWithMismatchedPluginDeviceID(t *testing.T) { rec := &recordingEmitter{} obs := newMetricsObserver(rec) - obs.onDeviceMetrics(context.Background(), 1, 0, "antminer", "ant-1", modelsV2.DeviceMetrics{ + obs.onDeviceMetrics(context.Background(), 1, 0, "asicrs", "ant-1", modelsV2.DeviceMetrics{ DeviceIdentifier: "ant-spoofed", HashrateHS: metricVal(110e12), TempC: metricVal(70), diff --git a/server/internal/domain/telemetry/service_test.go b/server/internal/domain/telemetry/service_test.go index 699e1528cb..c432306da0 100644 --- a/server/internal/domain/telemetry/service_test.go +++ b/server/internal/domain/telemetry/service_test.go @@ -1609,7 +1609,7 @@ func TestStatusWriterRoutine_FlushUsesWorkerSuppliedLabels(t *testing.T) { deviceIdentifier: models.DeviceIdentifier("dev-B"), status: mm.MinerStatusOffline, orgID: 99, - driverName: "antminer", + driverName: "asicrs", } require.Eventually(t, func() bool { @@ -1640,7 +1640,7 @@ func TestStatusWriterRoutine_FlushUsesWorkerSuppliedLabels(t *testing.T) { devB, ok := byDevice["dev-B"] require.True(t, ok, "expected an onDeviceStatus event for dev-B") require.Equal(t, "99", devB.labels.OrganizationID) - require.Equal(t, "antminer", devB.labels.Driver) + require.Equal(t, "asicrs", devB.labels.Driver) require.False(t, devB.online, "MinerStatusOffline should map to online=false") } @@ -1725,7 +1725,7 @@ func TestStatusWriterRoutine_FirmwareUpdateGuardDoesNotSuppressOnlineMetric(t *t deviceIdentifier: healthyDevice, status: mm.MinerStatusActive, orgID: 42, - driverName: "antminer", + driverName: "asicrs", } // Give the writer goroutine time to drain statusResults into pendingUpdates @@ -2980,7 +2980,7 @@ func TestFetchStatusFromMiner_ConnectionErrorResolvesOrgFromDeviceStore(t *testi mockDeviceStore.EXPECT(). GetDeviceOrgDriverAndSite(gomock.Any(), deviceID). - Return(int64(42), "antminer", int64(0), nil) + Return(int64(42), "asicrs", int64(0), nil) service := NewTelemetryService(Config{ StalenessThreshold: 1 * time.Minute, @@ -2993,7 +2993,7 @@ func TestFetchStatusFromMiner_ConnectionErrorResolvesOrgFromDeviceStore(t *testi require.NoError(t, err) assert.Equal(t, mm.MinerStatusOffline, status) assert.Equal(t, int64(42), orgID, "trusted org_id from device store must label the offline sample") - assert.Equal(t, "antminer", driverName, "trusted driver_name from device store must label the offline sample") + assert.Equal(t, "asicrs", driverName, "trusted driver_name from device store must label the offline sample") } // TestFetchStatusFromMiner_ConnectionErrorWithMissingDeviceRowDowngradesGracefully diff --git a/server/internal/testutil/database_queries.go b/server/internal/testutil/database_queries.go index aeea2ad781..06607a022c 100644 --- a/server/internal/testutil/database_queries.go +++ b/server/internal/testutil/database_queries.go @@ -291,7 +291,7 @@ func (s *DatabaseService) CreateTestMiners(orgID int64, count int, mockMinerURL driverName := "proto" if portStr == "4028" { - driverName = "antminer" + driverName = "asicrs" } deviceIDs := make([]string, count) diff --git a/server/migrations/000087_migrate_antminer_to_asicrs.down.sql b/server/migrations/000087_migrate_antminer_to_asicrs.down.sql new file mode 100644 index 0000000000..0bb00c9e2d --- /dev/null +++ b/server/migrations/000087_migrate_antminer_to_asicrs.down.sql @@ -0,0 +1,10 @@ +-- Revert stock Antminers back to the removed Go antminer driver name for rollback. +UPDATE discovered_device +SET driver_name = 'antminer', + port = CASE WHEN port = '80' THEN '4028' ELSE port END +WHERE driver_name = 'asicrs' + AND deleted_at IS NULL + AND ( + LOWER(COALESCE(manufacturer, '')) = 'bitmain' + OR LOWER(COALESCE(model, '')) LIKE 'antminer%' + ); diff --git a/server/migrations/000087_migrate_antminer_to_asicrs.up.sql b/server/migrations/000087_migrate_antminer_to_asicrs.up.sql new file mode 100644 index 0000000000..a13207d782 --- /dev/null +++ b/server/migrations/000087_migrate_antminer_to_asicrs.up.sql @@ -0,0 +1,6 @@ +-- Migrate devices discovered by the removed Go antminer plugin to ASIC-RS. +UPDATE discovered_device +SET driver_name = 'asicrs', + port = CASE WHEN port = '4028' THEN '80' ELSE port END +WHERE driver_name = 'antminer' + AND deleted_at IS NULL; diff --git a/tests/plugin-contract/harness/antminer.go b/tests/plugin-contract/harness/antminer.go deleted file mode 100644 index 1bd888c5bd..0000000000 --- a/tests/plugin-contract/harness/antminer.go +++ /dev/null @@ -1,21 +0,0 @@ -package harness - -import ( - "fmt" - "testing" - - sdk "github.com/block/proto-fleet/server/sdk/v1" -) - -// StartAntminer starts the Antminer plugin binary with the given web port override -// and cache TTL disabled for testing. -func StartAntminer(t testing.TB, webPort int) sdk.Driver { - t.Helper() - - env := map[string]string{ - "ANTMINER_WEB_PORT": fmt.Sprintf("%d", webPort), - "ANTMINER_STATUS_CACHE_TTL": "0s", - } - - return StartPlugin(t, "antminer-plugin", "", env) -} diff --git a/tests/plugin-contract/miners/antminer_stock_test.go b/tests/plugin-contract/miners/antminer_stock_test.go index 2aaa0e132c..974cfa084b 100644 --- a/tests/plugin-contract/miners/antminer_stock_test.go +++ b/tests/plugin-contract/miners/antminer_stock_test.go @@ -23,7 +23,16 @@ func TestAntminerStock(t *testing.T) { // Arrange mock := antminer.NewServer(t, antminerTestdataDir) manifest := miners.LoadManifest(t, antminerTestdataDir+"/manifest.json") - driver := harness.StartAntminer(t, mock.WebPort()) + driver := harness.StartAsicrsWithConfig(t, `plugin: + log_level: debug + discovery_timeout_seconds: 10 + telemetry_cache_ttl_seconds: 0 + +miners: + antminer: + stock: + enabled: true +`) func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -38,7 +47,7 @@ func TestAntminerStock(t *testing.T) { Driver: driver, Manifest: manifest, MinerIP: mock.Host(), - MinerPort: fmt.Sprintf("%d", manifest.Ports["rpc"]), + MinerPort: fmt.Sprintf("%d", manifest.Ports["web"]), } t.Run("Discovery", func(t *testing.T) { miners.AssertDiscovery(t, tc) }) @@ -51,6 +60,7 @@ func TestAntminerStock(t *testing.T) { t.Run("Telemetry", func(t *testing.T) { miners.AssertTelemetry(t, tc, device) }) t.Run("Control", func(t *testing.T) { miners.AssertControl(t, tc, device, caps) }) t.Run("Configuration", func(t *testing.T) { miners.AssertConfiguration(t, tc, device, caps) }) + t.Run("Maintenance", func(t *testing.T) { miners.AssertMaintenance(t, device, caps) }) t.Run("Errors", func(t *testing.T) { miners.AssertErrors(t, tc, device) }) t.Run("Capabilities", func(t *testing.T) { miners.AssertCapabilities(t, tc, device) }) diff --git a/tests/plugin-contract/miners/contract.go b/tests/plugin-contract/miners/contract.go index c1d1cab9f5..5616f9e5de 100644 --- a/tests/plugin-contract/miners/contract.go +++ b/tests/plugin-contract/miners/contract.go @@ -8,6 +8,7 @@ package miners import ( "context" + "os" "testing" "time" @@ -226,6 +227,50 @@ func AssertConfiguration(t *testing.T, tc TestContext, device sdk.Device, caps s } } +func AssertMaintenance(t *testing.T, device sdk.Device, caps sdk.Capabilities) { + t.Helper() + + if caps["logs_download"] { + t.Run("download_logs_succeeds", func(t *testing.T) { + logs, more, err := device.DownloadLogs(TestCtx(t), nil, "") + + require.NoError(t, err) + assert.NotEmpty(t, logs, "logs should contain diagnostic output") + assert.False(t, more, "single-shot log downloads should not advertise more data") + }) + } + + if caps["update_miner_password"] { + t.Run("update_miner_password_rejects_wrong_current_password", func(t *testing.T) { + err := device.UpdateMinerPassword(TestCtx(t), "wrong-password", "new-admin") + + assert.Error(t, err) + }) + + t.Run("update_miner_password_succeeds", func(t *testing.T) { + err := device.UpdateMinerPassword(TestCtx(t), "admin", "new-admin") + + assert.NoError(t, err) + }) + } + + if caps["manual_upload"] { + t.Run("firmware_update_succeeds", func(t *testing.T) { + path := t.TempDir() + "/test-firmware.bin" + data := []byte("mock firmware image") + require.NoError(t, os.WriteFile(path, data, 0600)) + + err := device.FirmwareUpdate(TestCtx(t), sdk.FirmwareFile{ + Filename: "test-firmware.bin", + Size: int64(len(data)), + FilePath: path, + }) + + assert.NoError(t, err) + }) + } +} + func AssertErrors(t *testing.T, tc TestContext, device sdk.Device) { t.Helper() diff --git a/tests/plugin-contract/mockapi/antminer/server.go b/tests/plugin-contract/mockapi/antminer/server.go index 82dac22632..25ec7476b7 100644 --- a/tests/plugin-contract/mockapi/antminer/server.go +++ b/tests/plugin-contract/mockapi/antminer/server.go @@ -19,10 +19,10 @@ import ( ) type Server struct { - t testing.TB - dataDir string - rpcAddr string - webPort int + t testing.TB + dataDir string + rpcAddr string + webPort int // RPC server rpcListener net.Listener @@ -41,23 +41,27 @@ type Server struct { webOverrides map[string][]byte behaviors map[string]mockapi.ConnBehavior defaultBehavior mockapi.ConnBehavior + webPassword string } -// NewServer starts a mock Antminer server with RPC on 127.0.0.1:4028 and HTTP on a random port. -// Not safe for parallel tests (shares port 4028 with WhatsMiner mock). +// NewServer starts a mock Antminer server with RPC on 127.0.0.1:4028 and HTTP on port 80. +// Port 80 is required because ASIC-RS hardcodes stock Antminer HTTP connections to port 80. +// Not safe for parallel tests (shares ports 4028 and 80 with other mocks). func NewServer(t testing.TB, dataDir string) *Server { t.Helper() rpcAddr := "127.0.0.1:4028" rpcListener := mockapi.ListenWithRetry(t, rpcAddr) - httpListener, err := net.Listen("tcp", "127.0.0.1:0") + httpAddr := "127.0.0.1:80" + httpListener := mockapi.ListenWithRetry(t, httpAddr) + + _, portStr, err := net.SplitHostPort(httpListener.Addr().String()) if err != nil { rpcListener.Close() - t.Fatalf("failed to start mock Antminer HTTP server: %v", err) + httpListener.Close() + t.Fatalf("failed to parse mock Antminer HTTP address %q: %v", httpListener.Addr().String(), err) } - - _, portStr, _ := net.SplitHostPort(httpListener.Addr().String()) var webPort int fmt.Sscanf(portStr, "%d", &webPort) @@ -74,6 +78,7 @@ func NewServer(t testing.TB, dataDir string) *Server { rpcOverrides: make(map[string][]byte), webOverrides: make(map[string][]byte), behaviors: make(map[string]mockapi.ConnBehavior), + webPassword: "admin", } // Start RPC server @@ -243,26 +248,68 @@ func extractRPCCommand(req map[string]interface{}) string { // --- HTTP server (digest auth + CGI endpoints) --- func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + // Extract endpoint key from path: /cgi-bin/stats.cgi -> stats + endpoint := endpointKey(r.URL.Path) + // Simplified digest auth: require Authorization header with valid username. // First request without auth gets a 401 with WWW-Authenticate challenge. authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Digest ") { + // Firmware uploads are multipart streams. The mock accepts the initial + // upload request so tests don't depend on replaying a streaming body + // after the synthetic digest challenge. + if endpoint == "upgrade" { + s.recordHTTPCommand(endpoint) + s.writeHTTPFixture(w, endpoint) + return + } w.Header().Set("WWW-Authenticate", `Digest realm="antMiner Configuration", nonce="abc123def456", qop="auth", algorithm=MD5`) w.WriteHeader(http.StatusUnauthorized) return } - // Extract endpoint key from path: /cgi-bin/stats.cgi -> stats - endpoint := endpointKey(r.URL.Path) + s.recordHTTPCommand(endpoint) + if endpoint == "passwd" { + s.handlePasswordChange(w, r) + return + } + + s.writeHTTPFixture(w, endpoint) +} + +func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { + var payload struct { + CurrentPassword string `json:"curPwd"` + NewPassword string `json:"newPwd"` + ConfirmPassword string `json:"confirmPwd"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, `{"stats":"error","msg":"invalid JSON"}`, http.StatusBadRequest) + return + } s.mu.Lock() + defer s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if payload.CurrentPassword != s.webPassword || payload.NewPassword != payload.ConfirmPassword { + w.Write([]byte(`{"stats":"error","msg":"invalid password"}`)) + return + } + s.webPassword = payload.NewPassword + w.Write(s.webResponses["passwd"]) +} + +func (s *Server) recordHTTPCommand(endpoint string) { + s.mu.Lock() + defer s.mu.Unlock() s.commands = append(s.commands, "web:"+endpoint) - s.mu.Unlock() +} +func (s *Server) writeHTTPFixture(w http.ResponseWriter, endpoint string) { resp := s.loadWebResponse(endpoint) if resp == nil { - http.NotFound(w, r) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } diff --git a/tests/plugin-contract/testdata/antminer-stock/manifest.json b/tests/plugin-contract/testdata/antminer-stock/manifest.json index 6fd29b5a66..406fdd93e4 100644 --- a/tests/plugin-contract/testdata/antminer-stock/manifest.json +++ b/tests/plugin-contract/testdata/antminer-stock/manifest.json @@ -2,11 +2,12 @@ "model": "Antminer S19", "manufacturer": "Bitmain", "firmware": "stock", - "firmware_version": "1.0.0", - "plugin": "antminer", + "firmware_version": "Fri Nov 17 17:37:21 CST 2023", + "plugin": "asicrs", "recorded_from": "172.16.26.253", "recorded_at": "2026-03-12T10:00:00Z", "ports": { - "rpc": 4028 + "rpc": 4028, + "web": 80 } } diff --git a/tests/plugin-contract/testdata/antminer-stock/rpc/devdetails.json b/tests/plugin-contract/testdata/antminer-stock/rpc/devdetails.json new file mode 100644 index 0000000000..5666475ca0 --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/rpc/devdetails.json @@ -0,0 +1,22 @@ +{ + "STATUS": [ + { + "STATUS": "S", + "When": 1741500000, + "Code": 69, + "Msg": "Device Details", + "Description": "bmminer" + } + ], + "DEVDETAILS": [ + { + "DEVDETAILS": 0, + "Name": "SM", + "ID": 0, + "Driver": "bmminer", + "Kernel": "", + "Model": "Antminer S19" + } + ], + "id": 1 +} diff --git a/tests/plugin-contract/testdata/antminer-stock/rpc/stats.json b/tests/plugin-contract/testdata/antminer-stock/rpc/stats.json new file mode 100644 index 0000000000..e3056e557a --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/rpc/stats.json @@ -0,0 +1,75 @@ +{ + "STATS": [ + { + "BMMiner": "1.0.0", + "CompileTime": "Fri Nov 17 17:37:21 CST 2023", + "Miner": "49.0.1.3", + "Type": "Antminer S19" + }, + { + "Calls": 0, + "Elapsed": 501234, + "GHS 5s": 88422.0, + "GHS av": 87900.0, + "ID": "BTM_SOC0", + "Max": 0, + "Min": 99999999, + "Mode": 2, + "STATS": 0, + "Wait": 0, + "chain_acn1": 88, + "chain_acn2": 88, + "chain_acn3": 88, + "chain_acs1": " ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo", + "chain_acs2": " ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo", + "chain_acs3": " ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo ooo", + "chain_hw1": 4, + "chain_hw2": 5, + "chain_hw3": 3, + "chain_rate1": "29500.00", + "chain_rate2": "29600.00", + "chain_rate3": "29322.00", + "fan1": 4080, + "fan2": 4380, + "fan3": 3750, + "fan4": 3690, + "fan_num": 4, + "freq1": 675, + "freq2": 675, + "freq3": 675, + "frequency": 675, + "miner_count": 3, + "miner_id": "8144d5ca79104814", + "miner_version": "49.0.1.3", + "rate_30m": 88100.0, + "rate_unit": "GH", + "temp1": 45, + "temp2": 46, + "temp3": 47, + "temp2_1": 67, + "temp2_2": 68, + "temp2_3": 70, + "temp_chip1": "55-57-65-67", + "temp_chip2": "56-58-66-68", + "temp_chip3": "57-59-68-70", + "temp_num": 3, + "temp_pcb1": "40-42-45-47", + "temp_pcb2": "41-43-46-48", + "temp_pcb3": "42-44-47-49", + "total rate": 87900.0, + "total_acn": 264, + "total_freqavg": 675, + "total_rateideal": 95000.0 + } + ], + "STATUS": [ + { + "Code": 70, + "Description": "bmminer", + "Msg": "CGMiner stats", + "STATUS": "S", + "When": 1741500000 + } + ], + "id": 1 +} diff --git a/tests/plugin-contract/testdata/antminer-stock/web/get_blink_status.json b/tests/plugin-contract/testdata/antminer-stock/web/get_blink_status.json new file mode 100644 index 0000000000..70060be834 --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/web/get_blink_status.json @@ -0,0 +1,3 @@ +{ + "blink": false +} diff --git a/tests/plugin-contract/testdata/antminer-stock/web/log.txt b/tests/plugin-contract/testdata/antminer-stock/web/log.txt new file mode 100644 index 0000000000..4c96308087 --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/web/log.txt @@ -0,0 +1,2 @@ +2026-03-12 10:00:00 bmminer: started +2026-03-12 10:00:01 network: link up diff --git a/tests/plugin-contract/testdata/antminer-stock/web/miner_type.json b/tests/plugin-contract/testdata/antminer-stock/web/miner_type.json new file mode 100644 index 0000000000..d4562bccc0 --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/web/miner_type.json @@ -0,0 +1,4 @@ +{ + "miner_type": "Antminer S19", + "subtype": "" +} diff --git a/tests/plugin-contract/testdata/antminer-stock/web/passwd.json b/tests/plugin-contract/testdata/antminer-stock/web/passwd.json new file mode 100644 index 0000000000..3d01ac905c --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/web/passwd.json @@ -0,0 +1,3 @@ +{ + "stats": "success" +} diff --git a/tests/plugin-contract/testdata/antminer-stock/web/upgrade.json b/tests/plugin-contract/testdata/antminer-stock/web/upgrade.json new file mode 100644 index 0000000000..f68532f440 --- /dev/null +++ b/tests/plugin-contract/testdata/antminer-stock/web/upgrade.json @@ -0,0 +1,4 @@ +{ + "code": "U000", + "stats": "success" +}