package client import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "sync" "time" "crypto-miner-agent/config" ) // GPUVendor identifies the discrete GPU brand on the host. type GPUVendor int const ( GPUVendorNone GPUVendor = iota GPUVendorNVIDIA // use T-Rex miner (KawPoW) GPUVendorAMD // use TeamRedMiner (KawPoW) GPUVendorOther // generic / Intel — not supported for KawPoW ) // GPUInfo holds detected GPU metadata. type GPUInfo struct { Vendor GPUVendor Model string } // GPUMinerStats is polled from the miner's local HTTP API. type GPUMinerStats struct { Hashrate15s float64 Hashrate1m float64 Hashrate15m float64 GPUTempC *int GPUUsagePct *int ActiveAlgo string } // GPUMiner manages one GPU miner sub-process (T-Rex or TeamRedMiner). type GPUMiner struct { cfg config.RuntimeConfig info GPUInfo installDir string mu sync.RWMutex stats GPUMinerStats active bool stopCh chan struct{} wg sync.WaitGroup } // newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected. // Returns nil if GPU mining should not run. func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner { if !cfg.GPUEnabled || cfg.RVNWallet == "" { return nil } info := detectGPU() if info.Vendor == GPUVendorNone || info.Vendor == GPUVendorOther { log.Printf("[gpu] GPU mining enabled but no supported GPU detected (vendor=%v model=%q)", info.Vendor, info.Model) return nil } installDir, err := cfg.InstallDirectory() if err != nil { log.Printf("[gpu] cannot determine install dir: %v", err) return nil } log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model) return &GPUMiner{ cfg: cfg, info: info, installDir: installDir, stopCh: make(chan struct{}), } } // Start downloads (if needed) and launches the GPU miner, then polls stats. func (g *GPUMiner) Start() { g.wg.Add(1) go func() { defer g.wg.Done() g.run() }() } // Stop shuts down the GPU miner and waits for it to exit. func (g *GPUMiner) Stop() { select { case <-g.stopCh: default: close(g.stopCh) } g.wg.Wait() } // Stats returns the latest GPU mining statistics. func (g *GPUMiner) Stats() (GPUMinerStats, bool) { g.mu.RLock() defer g.mu.RUnlock() return g.stats, g.active } // GPUModel returns the detected GPU model string. func (g *GPUMiner) GPUModel() string { return g.info.Model } func (g *GPUMiner) run() { // Ensure the miner binary is present before trying to start. binPath, err := g.ensureMinerBinary() if err != nil { log.Printf("[gpu] could not obtain miner binary: %v", err) return } retryDelay := 30 * time.Second for { select { case <-g.stopCh: return default: } proc, err := g.startProcess(binPath) if err != nil { log.Printf("[gpu] failed to start miner process: %v — retry in %s", err, retryDelay) select { case <-g.stopCh: return case <-time.After(retryDelay): continue } } g.mu.Lock() g.active = true g.mu.Unlock() log.Printf("[gpu] miner started (pid=%d)", proc.Pid) // Poll miner API while it runs. pollDone := make(chan struct{}) go func() { defer close(pollDone) g.pollStats() }() // Wait for process exit. procState, waitErr := proc.Wait() close(g.stopCh) // signal polling goroutine <-pollDone g.mu.Lock() g.active = false g.mu.Unlock() if waitErr != nil { log.Printf("[gpu] miner exited: %v", waitErr) } else if procState != nil && !procState.Success() { log.Printf("[gpu] miner exited with non-zero status: %s", procState) } // Re-open the stop channel so we can retry cleanly. g.stopCh = make(chan struct{}) select { case <-time.After(retryDelay): } } } func (g *GPUMiner) pollStats() { apiPort := g.apiPort() ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() samples := make([]float64, 0, 90) // 15 min worth at 10s intervals for { select { case <-g.stopCh: return case <-ticker.C: hr, tempC, usage, err := fetchMinerStats(g.info.Vendor, apiPort) if err != nil { continue } samples = append(samples, hr) if len(samples) > 90 { samples = samples[len(samples)-90:] } avg15s := hr avg1m := avg(samples, 6) avg15m := avg(samples, len(samples)) g.mu.Lock() g.stats = GPUMinerStats{ Hashrate15s: avg15s, Hashrate1m: avg1m, Hashrate15m: avg15m, GPUTempC: tempC, GPUUsagePct: usage, ActiveAlgo: "kawpow", } g.mu.Unlock() } } } func avg(samples []float64, last int) float64 { if len(samples) == 0 || last <= 0 { return 0 } if last > len(samples) { last = len(samples) } slice := samples[len(samples)-last:] var sum float64 for _, v := range slice { sum += v } return sum / float64(len(slice)) } func (g *GPUMiner) apiPort() int { switch g.info.Vendor { case GPUVendorNVIDIA: return 4067 case GPUVendorAMD: return 4068 default: return 4067 } } // ---- Miner binary management ---- type minerSpec struct { fileName string downloadURL string } func (g *GPUMiner) spec() minerSpec { switch g.info.Vendor { case GPUVendorNVIDIA: return minerSpec{ fileName: "t-rex.exe", downloadURL: "https://github.com/trexminer/T-Rex/releases/download/0.26.8/t-rex-0.26.8-win.zip", } default: // AMD return minerSpec{ fileName: "teamredminer.exe", downloadURL: "https://github.com/todxx/teamredminer/releases/download/v0.10.21/teamredminer-v0.10.21-win.zip", } } } func (g *GPUMiner) ensureMinerBinary() (string, error) { spec := g.spec() binPath := filepath.Join(g.installDir, spec.fileName) if _, err := os.Stat(binPath); err == nil { return binPath, nil } log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL) if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil { return "", fmt.Errorf("download failed: %w", err) } if _, err := os.Stat(binPath); err != nil { return "", fmt.Errorf("binary not found after download: %s", binPath) } return binPath, nil } func downloadAndExtract(url, destDir, targetFile string) error { resp, err := http.Get(url) //nolint:noctx if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } data, err := io.ReadAll(resp.Body) if err != nil { return err } return extractZipFile(data, destDir, targetFile) } // ---- Miner HTTP API polling ---- // T-Rex summary response (subset we care about). type trexSummary struct { Hashrate int `json:"hashrate"` GPUs []struct { Temperature int `json:"temperature"` GpuLoad int `json:"gpu_load"` } `json:"gpus"` } // TeamRedMiner status response (subset). type trmStatus struct { Algorithms []struct { Name string `json:"algorithm"` TotalMHs float64 `json:"mhsh_total"` } `json:"algorithms"` GPUs []struct { TempC int `json:"temp_c"` Fan int `json:"fan_pct"` } `json:"gpus"` } func fetchMinerStats(vendor GPUVendor, port int) (hashrate float64, tempC, usagePct *int, err error) { url := fmt.Sprintf("http://127.0.0.1:%d/summary", port) resp, e := http.Get(url) //nolint:noctx if e != nil { return 0, nil, nil, e } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) switch vendor { case GPUVendorNVIDIA: var s trexSummary if e := json.Unmarshal(body, &s); e != nil { return 0, nil, nil, e } hashrate = float64(s.Hashrate) if len(s.GPUs) > 0 { t := s.GPUs[0].Temperature u := s.GPUs[0].GpuLoad tempC = &t usagePct = &u } case GPUVendorAMD: var s trmStatus if e := json.Unmarshal(body, &s); e != nil { return 0, nil, nil, e } for _, a := range s.Algorithms { if a.Name == "kawpow" || a.Name == "KawPoW" { hashrate = a.TotalMHs * 1e6 // convert MH/s → H/s } } if len(s.GPUs) > 0 { t := s.GPUs[0].TempC u := s.GPUs[0].Fan tempC = &t usagePct = &u } } return }