diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..340ce963 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary \ No newline at end of file diff --git a/README.md b/README.md index c8d35501..c77d3829 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ designed to communicate with various Bitcoin mining hash boards via USB serial interfaces. Part of the larger Mujina OS project, an open source, Debian-based embedded Linux distribution optimized for Bitcoin mining hardware. +This repository also includes an active Rust port of the Intel BZM2 mining +stack. The goal of the port is to keep BZM2 support inside Mujina rather than +reviving the original split `cgminer` plus `bzmd` process model. + ## Features - **Heterogeneous Multi-Board Support**: Mix and match different hash board @@ -34,18 +38,34 @@ embedded Linux distribution optimized for Bitcoin mining hardware. - **Accessible Development**: Start developing with minimal hardware; a laptop and a single [Bitaxe](mujina-miner/src/board/bitaxe_gamma.md) board is enough to contribute meaningfully +- **BZM2 Port In Progress**: Native Rust BZM2 support with direct UART work + dispatch, result parsing, telemetry, debug tooling, and startup tuning flows ## Supported Hardware Currently supported: - [**Bitaxe Gamma**](mujina-miner/src/board/bitaxe_gamma.md) with BM1370 ASIC +Experimental support in this repository: +- **BZM2 boards** via Mujina's native Rust BZM2 path + - [Satoshi Starter](https://github.com/Blockscale-Solutions/SatoshiStarter) + - [bitaxeBIRDS](https://github.com/bitaxeorg/bitaxeBIRDS) + - direct UART mining path + - PLL and DLL diagnostics + - DTS/VS telemetry through the API + - on-demand DTS/VS query support through CLI and HTTP API + - silicon-validation helpers adapted from the legacy silicon validation stack + Planned support: - **EmberOne** with BM1362 ASIC -- **EmberOne** with Intel BZM2 ASICs - Antminer S19j Pro hash boards - Any and all ASIC mining hardware +The BZM2 work is functional and test-covered, but still not presented as +production-ready hardware support. The remaining gap is mostly board-specific +bring-up and manufacturing/operations integration rather than the core UART +ASIC path. + ## Documentation ### Project Documentation @@ -53,6 +73,22 @@ Planned support: - [Architecture Overview](docs/architecture.md) - System design and component interaction - [REST API](docs/api.md) - API contract, conventions, and endpoints +- [BZM2 Port Note](docs/bzm2/bzm2-port.md) - Architecture, implemented behavior, + telemetry, and current scope boundaries for the BZM2 port +- [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) - CLI usage for UART, + telemetry queries, TDM observation, clock diagnostics, and validation flows +- [BZM2 Tuning Planner](docs/bzm2/bzm2-pnp.md) - Tuning-planner behavior and current + calibration scope +- [BZM2 Opcode Grounding](docs/bzm2/bzm2-opcode-grounding.md) - Source-grounded UART + opcode behavior and the current JTAG evidence boundary +- [Blockscale ASIC Integration Guide](docs/bzm2/blockscale-asic-integration-guide.md) - + Generic hardware design guidance for building a custom solution around the + Blockscale / BZM2 ASIC +- [Blockscale UART And TDM Reference](docs/bzm2/blockscale-uart-protocol-reference.md) - + ASIC-facing UART, TDM, opcode, and job-programming reference +- [Blockscale Reference Roadmap](docs/bzm2/blockscale-reference-roadmap.md) - + Ordered implementation plan for closing the remaining bring-up, tuning, and + diagnostics gaps - [CPU Mining](docs/cpu-mining.md) - Run without hardware for development and testing - [Container Image](docs/container.md) - Build and run as a container @@ -127,6 +163,33 @@ Without `MUJINA_POOL_URL`, the miner runs with a dummy job source that generates synthetic mining work, which is useful for testing hardware without a pool connection. +### BZM2 Quick Start + +Enable the BZM2 path by pointing Mujina at one or more serial devices: + +```bash +MUJINA_BZM2_SERIAL="/dev/ttyUSB0" \ +MUJINA_BZM2_BAUD="5000000" \ +MUJINA_BZM2_DTS_VS_GEN="2" \ +cargo run -p mujina-miner --bin minerd +``` + +Useful companion tooling: + +```bash +# Query one ASIC's DTS/VS telemetry directly +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 + +# Read refreshed board telemetry over HTTP +curl -X POST http://127.0.0.1:7785/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +See [BZM2 UART Debug Guide](docs/bzm2/bzm2-uart-debug.md) for the full command +surface. + ### API Server The REST API listens on `127.0.0.1:7785` by default. To listen @@ -190,6 +253,18 @@ commands, PMBus/I2C power management, and fan control. See [tools/mujina-dissect/README.md](tools/mujina-dissect/README.md) for detailed usage and documentation. +## Validation Status + +As of the current BZM2 porting work, the full Linux-side `mujina-miner` test +suite passes in WSL with: + +- `327 passed` +- `0 failed` +- `5 ignored` + +That validation includes the BZM2-specific protocol, thread, board, telemetry, +API, and debug-tooling coverage added in this repository. + ## License This project is licensed under the GNU General Public License v3.0 or later. @@ -214,3 +289,7 @@ started. Bitcoin mining hashboard - [emberone-usbserial-fw](https://github.com/256foundation/emberone-usbserial-fw) - Firmware for EmberOne boards +- [Satoshi Starter](https://github.com/Blockscale-Solutions/SatoshiStarter) - + BZM2-based open hardware carrier from Blockscale Solutions +- [bitaxeBIRDS](https://github.com/bitaxeorg/bitaxeBIRDS) - + BZM2-based Bitaxe-family board design diff --git a/docs/bzm2/blockscale-asic-integration-guide.md b/docs/bzm2/blockscale-asic-integration-guide.md new file mode 100644 index 00000000..a2090781 --- /dev/null +++ b/docs/bzm2/blockscale-asic-integration-guide.md @@ -0,0 +1,546 @@ +# Blockscale / BZM2 ASIC Hardware Integration Guide + +## Purpose + +This document consolidates ASIC-level behavior needed to design a custom +hardware solution around the Blockscale / BZM2 mining ASIC. It focuses on +generic implementation requirements: + +- power architecture +- sequencing +- clocking +- UART transport +- multi-ASIC chaining +- telemetry +- protection +- tuning and calibration + +It deliberately avoids copying vendor reference-board implementation details. +Those reference systems are useful examples, but they are not required for a +working design. + +## Scope + +This guide is for hardware developers building: + +- a single-ASIC board +- a small multi-ASIC board with one or a few voltage domains +- a larger multi-ASIC platform with multiple voltage domains + +The core constraint does not change with board size: every ASIC contains an +internal dual-stack engine arrangement and expects the host system to sequence, +clock, load, and protect it correctly. + +## ASIC At A Glance + +The vendor collateral and legacy host software consistently describe the +following ASIC-level properties: + +- `236` hashing engine tiles per ASIC +- `4` engines per tile +- `944` total engines per ASIC +- `2` primary PLL domains, corresponding to the bottom and top engine stacks +- on-die digital temperature sensing +- on-die three-channel voltage sensing +- UART as the primary host control and mining transport +- unicast, multicast, and broadcast job/register distribution +- TDM streaming for results, register responses, `NOOP`, and sensor data + +Throughput per ASIC can be estimated as: + +```text +Throughput (GH/s) = 236 * 4 * PLL_Frequency / 3 * Pass_Rate +``` + +Where: + +- `PLL_Frequency` is in GHz +- `Pass_Rate` is `0.0` to `1.0` + +Example: + +```text +1.2 GHz * (236 * 4 / 3) * 1.0 = 377.6 GH/s +``` + +That formula is useful for sizing cooling, PSU headroom, calibration targets, +and per-domain operating points. + +## Recommended System Partitioning + +The ASIC does not require a specific vendor platform. It does require that the +overall system provide the following functions: + +- stable control-side power +- stable stack-side power +- reference clock generation +- reset and trip handling +- UART master +- telemetry collection and protection logic +- work generation and result collection + +```mermaid +flowchart LR + Host["Host SoC / MCU / FPGA"] --> UART["UART Master"] + Host --> Power["Power / PMBus / Regulators"] + Host --> Cooling["Fan / Pump / Thermal Control"] + Host --> API["Control API / UI"] + UART --> Chain["ASIC Chain"] + Power --> Chain + Cooling --> Chain + Chain --> Sensors["DTS / VS / TRIP"] + Sensors --> Host +``` + +The exact split is your design choice: + +- a Linux SoC can own all logic directly +- an MCU can own power sequencing while a host CPU owns mining +- an FPGA can assist with fanout, timing, or board aggregation + +The ASIC-facing requirements remain the same. + +## Package, IO, And Mechanical Constraints + +The extracted vendor collateral describes: + +- package size: `7.5 x 7 mm` +- package type: exposed-die molded `FCLGA` +- total signal / land interfaces sized around `60` SLI pads and `60` LGA pads +- operating junction target range: roughly `55 C` to `85 C` +- absolute maximum junction temperature: `115 C` + +Design implications: + +- provide strong top-side thermal extraction +- assume continuous high leakage once stack voltage is present +- do not depend on reset state to keep thermal rise negligible +- plan for heatsink and forced airflow, or an equivalent thermal solution + +Even a single-ASIC design should be treated as thermally active immediately +after hash rails are applied. + +## Core Rails And Voltage Architecture + +The ASIC is built around internal voltage stacking. The documents describe two +engine-stack ranges: + +- bottom stack: approximately `0.0 V` to `0.355 V` +- top stack: approximately `0.355 V` to `0.71 V` + +Additional named rails described in the vendor material: + +- GPIO / control IO: `1.2 V` +- `VDD_HASH`: nominal `0.71 V` +- `VDD_P75`: backup rail if the on-chip LDO path is unavailable + +### Why voltage stacking matters + +Voltage stacking is central to efficiency, but it also creates the main +hardware risk: + +- the absolute stack voltages must stay inside safe limits +- the differential between the stacks must stay controlled +- bad sequencing or poor balancing can create overvoltage or thermal runaway + +The ASIC includes internal voltage sensing specifically because the host must +monitor and react to stack imbalance. + +### Voltage sensor channels + +The internal voltage sensor reports three useful channels: + +- `ch0`: bottom stack voltage +- `ch1`: top stack voltage +- `ch2`: differential between the stacks + +For a custom design, treat those as first-class runtime safety inputs, not +debug-only data. + +## Clocking + +### External reference clock + +The ASIC expects an external reference clock on `REFCLKIN`. The extracted +datasheet text describes: + +- `REFCLKIN` as the ASIC reference clock input +- maximum reference clock on that pin up to `50 MHz` + +The same collateral also exposes `REFCLKOUT1` and `REFCLKOUT2`, primarily as +debug-oriented outputs. + +### Internal PLLs + +The ASIC uses two PLLs to feed the two internal engine stacks: + +- `PLL0`: bottom stack +- `PLL1`: top stack + +Bring-up implications: + +- both PLLs are disabled by default +- software must enable them explicitly +- software must wait for lock before releasing dependent logic + +When both divider classes are changed, the documented programming rule is: + +1. write `FBDIV` +2. write `POSTDIV` + +Do not reverse that order during live clock changes. + +### DLL health + +The legacy software also validates DLL state using `coarsecon` and `fincon` +status. That is not strictly required to boot the ASIC, but it is useful for: + +- manufacturing validation +- marginal-clock debug +- SI validation at new board layouts or cable lengths + +If you are building a custom carrier or long-chain design, budget time for DLL +health checks during validation. + +## UART And Chain Topology + +UART is the primary host interface for: + +- enumeration +- register control +- job dispatch +- result retrieval +- TDM streaming +- sensor retrieval + +### Practical UART assumptions + +The extracted materials and legacy software consistently use: + +- default ASIC baud: `5 Mbps` +- host notch / slow clock during bring-up: `50 MHz` + +The pad tables describe the UART-related pads as `1.2 V` IO. Treat this as a +real electrical requirement when selecting the host UART PHY or level-shifting +scheme. + +### Chain orientation and pin muxing + +The vendor material describes a `PINSEL`-based pin muxing arrangement where the +same physical pins can serve as: + +- `RX_IN` / `TX_OUT` +- `RESET_IN` / `RESET_OUT` +- `TRIP_IN` / `TRIP_OUT` + +This is what enables daisy-chain style system layouts. For a generic design, +the important point is: + +- your schematic must preserve a consistent direction through the chain +- reset and trip propagation need the same level of attention as RX/TX routing + +### ASIC enumeration model + +The documented enumeration flow is: + +1. all ASICs start with default `ASIC_ID = 0xFA` +2. the host addresses `0xFA` +3. the first visible ASIC responds +4. the host writes a unique `ASIC_ID` +5. writing the ID also unlocks `RX_OUT` +6. the next ASIC becomes reachable +7. repeat until the chain is assigned + +`NOOP` returning `BZ2` is the simplest chain-liveness check. + +### Broadcast and multicast + +The ASIC supports: + +- unicast to one engine in one ASIC +- broadcast to the same engine position across all ASICs +- multicast to a row group + +That capability is what makes large chains viable over UART. Use it for: + +- initial register programming +- dummy-job deployment +- broad frequency ramps +- row-wise validation + +Reserve unicast for: + +- ASIC ID assignment +- per-ASIC final tuning +- fault isolation +- result ownership and targeted debug + +## Power-Up And Bring-Up Sequence + +The vendor documents describe a specific reference flow, but the reusable logic +for any custom board is: + +```mermaid +flowchart TD + A["Apply control rails and reference clock"] --> B["Apply safe initial stack voltage"] + B --> C["Hold ASICs in reset"] + C --> D["Bring UART online at 5 Mbps"] + D --> E["Enumerate ASICs from default ID 0xFA"] + E --> F["Confirm NOOP = BZ2"] + F --> G["Initialize LDO-related state and ASIC IDs"] + G --> H["Program safe initial PLL frequency"] + H --> I["Wait for PLL lock"] + I --> J["Enable TDM if streaming is needed"] + J --> K["Submit dummy work to keep engines loaded"] + K --> L["Raise stack voltage gradually while monitoring VS"] + L --> M["Run tuning and calibration sweep"] + M --> N["Transition to production job dispatch"] +``` + +### Practical bring-up rules + +- start from a conservative voltage +- start from a conservative clock, typically much lower than final operating + point +- do not ramp voltage or frequency without sensor feedback +- do not leave engines idle during stack-balancing phases if your control + strategy depends on balanced load +- do not start full production mining until IDs, PLL lock, and basic telemetry + are confirmed + +### Dummy-job use is not optional in stacked systems + +The software guide and calibration material both treat dummy jobs as part of +power balancing, not merely a debug trick. In practice, dummy jobs help: + +- keep engines drawing current +- maintain stack balance during ramp-up +- prevent some engines from sitting unloaded while others are active +- hold a repeatable thermal and electrical state during calibration + +## Mining Programming Model + +### Enhanced mode + +Enhanced mode is the default engine programming mode. The documented sequence +for a valid four-lane engine-tile submission is: + +1. enable TCE clocks +2. program nonce bounds and target +3. load the four midstates +4. program four write-job sequences +5. only the fourth write enables execution + +The four logical writes share: + +- merkle root residue +- start timestamp + +They differ by: + +- midstate +- sequence ID + +### Job control behavior + +The documented `JobControl` modes matter operationally: + +- `0x1`: mark pending job ready +- `0x2`: cancel current and pending job, return to idle +- `0x3`: abort current job and immediately launch pending job + +That cancel path is essential for recovery from invalid or stale engine state. + +### Partial and invalid programming + +The hardware documents are explicit about failure behavior: + +- partial programming can consume bytes from a following write and create + unintended nonces +- launching before the fourth write can cause incomplete jobs to execute +- disabled TCE lanes still require software to maintain correct sequencing +- unused TCE lanes should be flushed with zeroed dummy content + +Do not assume the ASIC silently sanitizes malformed software behavior. + +## Telemetry And Protection + +### Temperature sensing + +The ASIC exposes a digital temperature sensor. The developer guide and legacy +software both use the same conversion family: + +```text +T = K + Y * (N - 2^11 / 2^R) / 2^12 +``` + +Where: + +- `T` = temperature in Celsius +- `N` = raw thermal tune code +- `R` = sensor resolution, typically `12` +- `Y = 631.8` +- `K = -293.8` + +At default 12-bit resolution, a raw code near `2084` maps to approximately +`27.6 C`. + +### Voltage sensing + +The voltage conversion used by the vendor guide and legacy implementation is: + +```text +V = 1000 * (2 / 5) * VREF * (6 * N / 2^14 - 3 / 2^R - 1) +``` + +Where: + +- `V` = uncalibrated voltage in mV +- `N` = raw sensor code +- `R` = voltage-sensor resolution +- `VREF = 0.7067` + +### Protection behavior + +The ASIC can assert a trip output when thermal or voltage thresholds are +exceeded. A robust system should wire this into board-level protection. + +Recommended policy: + +- use sensor data for continuous host-side supervision +- use the trip path for fast hardware or firmware response +- treat temperature and differential stack voltage as shutdown-class signals +- never rely on software polling alone for destructive fault containment + +## Calibration Methodology + +The calibration material is useful as methodology, but not as a fixed set of +numbers. The reusable sequence is: + +1. characterize a single ASIC or a small golden sample +2. choose conservative initial voltage and frequency +3. reset all ASICs to the safe starting point +4. bring stack voltage to a safe operating region +5. use dummy jobs to keep the electrical state stable +6. raise frequency in steps, commonly `25 MHz` coarse steps +7. measure pass rate, throughput, temperature, current, and power +8. raise voltage only if throughput targets cannot be met within thermal and + power limits +9. once the board-level operating region is found, fine-tune individual ASICs + in smaller steps, for example `6.25 MHz` +10. persist the resulting operating point for restart reuse + +### Calibration inputs that should be board-specific + +The following should be measured on your own design, not copied from a vendor +reference system: + +- PSU current limits +- PSU power limits +- board thermal limits +- acceptable stack imbalance +- safe junction temperature target +- fan or pump response curves +- pass-rate thresholds for field use + +### What scales from 1 ASIC to 100 ASICs + +A practical strategy for scale is: + +- characterize at the domain level first +- then fine-tune per ASIC + +For example: + +- `1 ASIC`: one domain, direct per-ASIC tuning +- `4 ASICs`: tune the shared rail first, then trim per ASIC if needed +- `100 ASICs`: first establish safe per-domain voltage and coarse clock, then + apply per-ASIC final offsets + +That is also the model implemented in the Rust tuning planner in this +repository. + +## Design Recommendations By System Size + +### Single-ASIC board + +Recommended priorities: + +- keep power sequencing simple and deterministic +- expose UART, reset, and trip for debug access +- expose DTS/VS in firmware or API from day one +- use direct per-ASIC characterization rather than heavy-weight broadcast flows + +### Small multi-ASIC board + +Recommended priorities: + +- decide early whether all ASICs truly share one rail policy +- keep chain routing short and deterministic +- implement broadcast writes and per-ASIC unicast verification +- maintain enough sensor visibility to identify one bad ASIC quickly + +### Large multi-domain system + +Recommended priorities: + +- treat domain balancing as a system function, not an afterthought +- separate board protection from mining software +- use broadcast for coarse actions and unicast for final trim +- persist calibration state and replay it on restart +- provide out-of-band observability for voltage, current, and trip events + +## Common Failure Modes + +Expect these classes of issues during bring-up: + +- chain breaks due to mux orientation or RX/TX direction errors +- false confidence from UART liveness before IDs or PLLs are fully initialized +- stack imbalance during idle or partial-load operation +- thermal runaway from insufficient cooling during early ramp +- residual engine programming causing unexpected nonces +- malformed partial write-job sequences +- assuming all engine IDs are contiguous or present + +Design for fast isolation: + +- per-domain current and voltage visibility +- easy reset control +- easy UART capture +- per-ASIC NOOP and register-read debug +- trip logging + +## Minimum Validation Checklist + +Before calling a hardware platform ready, verify: + +- control IO is truly `1.2 V` compatible +- reference clock integrity at the ASIC pin +- reset propagation through the entire chain +- per-ASIC enumeration from default ID +- `NOOP` response integrity across the full chain +- stable PLL lock across the intended operating range +- valid DTS/VS readings for every ASIC +- no dangerous stack imbalance at idle, dummy load, and production load +- sustained production pass rate at target operating point +- protection response for overtemperature and stack-voltage faults + +## Relationship To The Mujina Rust Implementation + +This repository already includes a practical Rust implementation of the core +ASIC behavior discussed above: + +- UART opcode support +- TDM parsing +- PLL and DLL diagnostics +- DTS/VS telemetry +- on-demand sensor query support +- startup tuning and saved operating-point replay +- silicon-validation CLI flows + +Relevant follow-on documents: + +- [UART and TDM Reference](blockscale-uart-protocol-reference.md) +- [BZM2 Port Note](bzm2-port.md) +- [BZM2 UART Debug Guide](bzm2-uart-debug.md) +- [BZM2 Tuning Planner](bzm2-pnp.md) diff --git a/docs/bzm2/blockscale-reference-roadmap.md b/docs/bzm2/blockscale-reference-roadmap.md new file mode 100644 index 00000000..a7749971 --- /dev/null +++ b/docs/bzm2/blockscale-reference-roadmap.md @@ -0,0 +1,332 @@ +# Blockscale / BZM2 Reference Implementation Roadmap + +## Goal + +Close the remaining gap between: + +- a strong ASIC-facing Rust port with solid debug tooling + +and + +- a comprehensive, reusable reference implementation for custom Blockscale / + BZM2 hardware. + +This roadmap is ordered by dependency and practical value. + +## Scope + +In scope: + +- generic ASIC bring-up +- generic chain discovery +- reusable domain-aware power and tuning control +- board/API diagnostics +- runtime retune + +Out of scope for this plan: + +- vendor reference-board reproduction +- carrier-specific MCU protocols unless a target board actually needs them +- Gen1 telemetry completion +- speculative JTAG implementation not grounded in concrete protocol evidence + +## Current Gap Summary + +The current repo already has: + +- UART opcode support +- TDM parsing +- mining dispatch and result handling +- PLL and DLL diagnostics +- DTS/VS telemetry and query tooling +- startup tuning planning and saved operating-point replay +- a strong silicon-validation CLI + +The biggest missing pieces are: + +1. runtime engine/topology discovery instead of fixed assumptions +2. closed-loop calibration and retune +3. board/API diagnostics parity with the CLI + +## Phase 1: Discoverable Bring-Up + +Objective: + +- eliminate the assumption that ASIC count and identity are fully preconfigured + +Deliverables: + +1. Add low-level UART helpers for: + - writing `ASIC_ID` + - enumerating a chain starting from default `0xFA` + - verifying assigned IDs with `NOOP` +2. Add debug CLI support for: + - chain enumeration + - ID assignment validation +3. Add optional board startup enumeration mode so `Bzm2Board` can populate bus + layout from hardware rather than only from `MUJINA_BZM2_ASICS_PER_BUS` + +Status: + +- completed: low-level default-`ASIC_ID` enumeration helpers +- completed: `enumerate-chain` CLI support +- completed: opt-in `Bzm2Board` startup enumeration with fallback to + configured topology when no default-id ASICs are present +- next: Phase 2, applied rail and reset control + +Exit criteria: + +- a powered chain can be discovered from software with no hard-coded ASIC count +- the discovered count can seed board topology and saved operating-point + compatibility checks + +## Phase 2: Applied Rail And Reset Control + +Objective: + +- move the existing control abstractions from library-only status into real + board startup and shutdown flows + +Deliverables: + +1. Wire `Bzm2BringupPlan` into `Bzm2Board` +2. Add a concrete board-facing rail bundle abstraction: + - one or more rails + - optional reset line + - optional rail telemetry +3. Apply safe startup and shutdown sequencing through the board runtime +4. Expose rail telemetry into board state where available + +Status: + +- completed: `Bzm2BringupPlan` is now wired into `Bzm2Board` startup and + shutdown through generic file-backed rail and reset adapters +- completed: optional file-backed rail telemetry now flows into `BoardState` +- next: map planned domain voltages onto those startup/shutdown hooks + +Exit criteria: + +- board startup can perform reset and rail sequencing without external manual + steps +- board shutdown returns the hardware to a safe state + +## Phase 3: Domain Voltage Application + +Objective: + +- make the tuning planner’s voltage-domain outputs real rather than advisory + +Deliverables: + +1. Map planned domain voltages onto configured rails +2. Apply coarse domain voltages before clock ramp +3. Use rail telemetry and ASIC `DTS_VS` readings to verify applied state +4. Persist replay metadata that distinguishes: + - clock-only replay + - full voltage-plus-clock replay + +Exit criteria: + +- `Bzm2Board` can apply multi-domain operating points, not just PLL maps + +Status: + +- completed: planner-generated per-domain voltages are now mapped onto the + configured rail-control path before PLL ramp +- completed: saved operating-point replay now reapplies persisted per-domain + voltages before clock replay +- completed: live calibration persists per-domain rail targets for restart + replay +- next: Phase 4, topology and defect discovery + +## Phase 4: Topology And Defect Discovery + +Objective: + +- stop assuming the default logical engine map is always the real map + +Deliverables: + +1. Add engine/topology probing helpers +2. Detect unavailable or disabled engines per ASIC +3. Feed the discovered engine map into: + - work dispatch + - validation helpers + - tuning calculations + +Exit criteria: + +- systems with missing or disabled engines do not need a code rebuild or static + exclusion map edit + +Status: + +- completed: TDM-sync engine probe helpers now detect physical engine presence + by reading `ENGINE_REG_END_NONCE`, matching the historical C detection path +- completed: the debug CLI now supports: + - `engine-probe` + - `discover-engine-map` +- completed: discovered per-ASIC engine maps can now be pushed into live + `BoardState.asics` through: + - `Bzm2Board` command handling + - the live BZM2 thread actor + - `POST /api/v0/boards/{name}/bzm2/discover-engines` +- completed: successful discovery scans now update the live BZM2 runtime engine + layout used by: + - work dispatch fanout + - result reconstruction + - share validation helpers +- completed: calibration input now consumes active-engine counts and missing + coordinates through: + - live pre-calibration engine discovery when enabled + - saved operating-point topology replay + - default-map fallback when no topology data exists +- completed: saved operating-point reuse and planned hashrate estimation now + normalize against real engine capacity instead of assuming every ASIC has the + default full map +- next: Phase 5, closed-loop calibration and retune + +## Phase 5: Closed-Loop Calibration And Retune + +Objective: + +- turn the startup planner into a true operating-point controller + +Deliverables: + +1. Measure and store real: + - pass rate + - throughput + - per-PLL behavior + - per-domain power +2. Feed those measurements back into the tuning planner +3. Add runtime retune triggers for: + - throughput regression + - thermal drift + - persistent voltage imbalance +4. Revalidate or invalidate saved operating points automatically + +Exit criteria: + +- tuning decisions are based on measured runtime behavior, not just startup + heuristics and persisted estimates + +Status: + +- completed: live BZM2 threads now maintain work-based runtime throughput + estimators for: + - whole-thread throughput + - per-ASIC throughput + - per-PLL throughput using the documented row 0-9 / row 10-19 stack split +- completed: `Bzm2Board` now samples and stores runtime tuning measurements + into live board state and an internal cache, including: + - board throughput + - per-ASIC throughput + - per-ASIC average pass rate + - per-PLL pass rate and throughput + - per-domain measured voltage and power +- completed: the board runtime now feeds those live measurements back into the + existing tuning planner and publishes the current planner decision through + board state, including: + - reuse-saved-operating-point decision + - needs-retune decision + - desired voltage / clock / accept-ratio targets + - planner notes +- completed: runtime retune triggers are now promoted only after configurable + persistence across monitor polls for: + - throughput regression + - thermal drift + - persistent voltage imbalance +- completed: saved operating point profiles now carry runtime validation state + and are automatically: + - marked `validated` after clean runtime sampling + - marked `invalidated` when persistent retune triggers fire + - excluded from direct replay and planner seeding on later restarts once + invalidated +- next: Phase 6, diagnostics and API parity + +## Phase 6: Diagnostics And API Parity + +Objective: + +- expose the most useful silicon-validation operations without requiring the + standalone CLI + +Deliverables: + +1. Board/API commands for: + - `NOOP` + - loopback + - register read/write + - clock report + - chain enumeration summary +2. Board-state visibility for: + - discovered ASIC count + - discovered engine count / disabled-engine map + - saved operating-point replay path + - current calibration and safety status + +Status: + +- completed: board/API parity now covers live BZM2 thread-routed commands for: + - `NOOP` + - loopback + - register read/write +- completed: those diagnostics are exposed through HTTP endpoints that preserve + UART ownership by routing through the live BZM2 thread actor +- completed: the board/API surface now exposes `clock-report` parity through + the same live thread actor, so operators can inspect PLL/DLL lock state and + clock-control registers without dropping to the standalone CLI +- completed: the board/API surface now exposes a chain-summary view with: + - current per-bus serial path + - global ASIC ranges + - total discovered/configured ASIC count + - the startup path that produced the active operating point + - saved operating point validation state +- completed: Phase 6 exit criteria are now met for the planned board/API + diagnostics slice +- next: + - surface more of the same diagnostics through board state where useful + - only add broader manufacturing parity if there is a clear operator need + +Exit criteria: + +- operators can perform high-value diagnostics through the board/API surface + without dropping to raw serial tooling + +## Phase 7: JTAG, Only If Grounded + +Objective: + +- add JTAG only if enough packet-level evidence exists to do it correctly + +Deliverables: + +1. protocol-evidence review +2. minimal IR/DR helpers only if grounded +3. debug-only tooling for validated use cases + +Exit criteria: + +- no guessed JTAG semantics enter the codebase + +## Immediate Execution Order + +The next concrete work items should be: + +1. implement generic chain enumeration and `ASIC_ID` assignment helpers +2. add a debug CLI command that enumerates a live chain +3. add optional board startup auto-enumeration using that helper +4. then wire generic rail/reset sequencing into `Bzm2Board` + +## Current Execution Status + +Started: + +- Phase 1 step 1 and step 2 + +Reason: + +- enumeration removes a major assumption from the current board runtime +- it is ASIC-generic +- it is directly grounded in documented and legacy UART behavior diff --git a/docs/bzm2/blockscale-uart-protocol-reference.md b/docs/bzm2/blockscale-uart-protocol-reference.md new file mode 100644 index 00000000..10c4fc04 --- /dev/null +++ b/docs/bzm2/blockscale-uart-protocol-reference.md @@ -0,0 +1,449 @@ +# Blockscale / BZM2 UART And TDM Protocol Reference + +## Purpose + +This document summarizes the ASIC-facing UART protocol, TDM behavior, and job +programming model needed to write host software or validation tooling for the +Blockscale / BZM2 ASIC family. + +It is written as a practical reference for: + +- firmware developers +- board bring-up engineers +- validation engineers +- host software developers + +## Link Characteristics + +The vendor material and legacy host software consistently assume: + +- default ASIC UART baud: `5 Mbps` +- `1.2 V` IO signaling on UART-related pads +- a reference / slow-clock environment derived from a `50 MHz` source during + early bring-up + +Practical recommendation: + +- establish communication at `5 Mbps` first +- validate signal integrity there before attempting any custom timing changes + +## Addressing Model + +Each command carries: + +- `ASIC_ID` +- opcode +- engine or group identifier +- register offset or data payload, depending on opcode + +The documented system addressing model allocates: + +- normal engine space for engine tiles +- top `0xFxx` engine-ID region for non-engine / local functions such as PLLs, + sensors, and internal controller blocks + +Do not assume engine IDs are contiguous or fully populated. The vendor material +explicitly allows holes due to disabled or missing engines. + +## ASIC Identification + +Relevant ID values used during system bring-up: + +- `0xFA`: default ID before enumeration +- `0xFF`: platform-wide broadcast ID + +Enumeration rule: + +- writing a unique `ASIC_ID` to an ASIC previously addressed at `0xFA` also + unlocks forwarding so the next ASIC becomes reachable + +## Routing Modes + +### Unicast + +Use unicast when you need: + +- one register or job to one engine in one ASIC +- per-ASIC tuning +- precise debug +- result ownership validation + +### Broadcast + +Use broadcast when you need: + +- the same action on the same target across all ASICs +- coarse startup programming +- coarse frequency ramps +- fast deployment of common work or dummy work + +### Multicast + +Use multicast when you need: + +- row-wise engine programming +- efficient fanout inside one ASIC or across ASICs +- validation flows that target equivalent engine positions + +The vendor material treats multicast write as a row-group fanout mechanism and +also uses it as the basis for some broadcast-style write patterns. + +## Opcode Summary + +| Opcode | Name | Primary use | Typical response | +| --- | --- | --- | --- | +| `0x0` | `WRITEJOB` | Write engine job state and launch work | No immediate payload response | +| `0x1` | `READRESULT` | Poll ASIC result buffer in non-TDM mode | Result record with status | +| `0x2` | `WRITEREG` | Write engine or local registers | No immediate payload response | +| `0x3` | `READREG` | Read engine or local registers | Register data | +| `0x4` | `MULTICAST_WRITE` | Write a row-group of engines | No immediate payload response | +| `0xD` | `DTS_VS` | Read thermal and voltage sensor data | Sensor payload | +| `0xE` | `LOOPBACK` | Echo payload for transport validation | Echoed payload | +| `0xF` | `NOOP` | Link and chain liveness test | ASCII `BZ2` | + +## Frame Structure + +The legacy software and opcode notes use a leading length field followed by the +actual command header and payload. + +### `NOOP` + +Purpose: + +- confirm the ASIC is synchronized with the host +- confirm chain reachability + +Behavior: + +- transmit a short command to a specific ASIC +- receive three ASCII bytes: `BZ2` + +Practical use: + +- first link test after setting baud +- chain walk after programming IDs +- low-risk sanity check during debug + +### `WRITEJOB` + +Purpose: + +- deliver engine work over UART + +Documented behavior: + +- writes `42` consecutive bytes of job state +- typical transmit length is `48` bytes including framing + +Payload content includes: + +- `32` bytes of midstate +- merkle root residue +- start timestamp +- sequence ID +- job control + +Header construction described in the opcode notes: + +```text +header = (asic << 24) | (WRITEJOB << 20) | (engine << 8) | 41 +``` + +### `READRESULT` + +Purpose: + +- poll the ASIC-level result buffer directly in non-TDM mode + +Returned fields include: + +- status +- engine ID +- sequence ID +- nonce +- timestamp + +A clear valid bit is not the same as a framing error. The host is expected to +consume the full response length even when no valid result is present. + +### `WRITEREG` + +Purpose: + +- write a target register range + +Documented behavior: + +- length is `7 + count` +- payload uses a byte count field encoded as `count - 1` + +Common uses: + +- enabling clocks +- programming nonce bounds and target +- local-register setup for TDM, clocking, or sensors + +### `READREG` + +Purpose: + +- read a target register range + +Practical uses: + +- register validation during bring-up +- ASIC-local debug +- clock and sensor status inspection + +### `MULTICAST_WRITE` + +Purpose: + +- write one register range to multiple engines identified by a row-group ID + +Common uses: + +- row-wise initialization +- engine-wide or board-wide debug patterns +- efficient deployment of common register state + +### `LOOPBACK` + +Purpose: + +- validate the link by echoing a chosen payload + +Use cases: + +- transport validation before full job submission +- characterization of chain reliability at length +- regression tests after changing clocks, cabling, or PHY conditions + +### `DTS_VS` + +Purpose: + +- retrieve thermal and voltage-sensor data + +It can be used: + +- directly as a query +- indirectly through TDM streaming when enabled + +## Enhanced-Mode Job Programming + +Enhanced mode is the normal software contract for engine operation. + +Required setup before job submission: + +- enable TCE clocks via config register `0x01` +- program `StartNonce` at `0x3C` +- program `EndNonce` at `0x40` +- program `Target` at `0x44` +- load the four midstates beginning at `0x10` + +### Four-write sequence + +| Write | `0x30` MR residue | `0x34` start timestamp | `0x38` sequence ID | `0x39` job control | +| --- | --- | --- | --- | --- | +| `WriteJob_0` | same value | same value | `0b00` | `0x0` | +| `WriteJob_1` | same value | same value | `0b01` | `0x0` | +| `WriteJob_2` | same value | same value | `0b10` | `0x0` | +| `WriteJob_3` | same value | same value | `0b11` | `0x1` or `0x3` | + +Key rule: + +- only the fourth write should launch execution + +If `JobControl` is asserted too early, the job can be launched in an incomplete +state. + +## Job Control Semantics + +The documented values are: + +- `0x0`: clear pending only +- `0x1`: mark pending ready +- `0x2`: clear current and pending, return to idle +- `0x3`: abort current and immediately promote pending + +Practical guidance: + +- use `0x1` for orderly sequencing +- use `0x3` for preemption when the current search space is stale +- use `0x2` to recover from invalid or hung engine state before resuming work + +## Partial, Incomplete, And Invalid Programming + +The hardware documentation is explicit that bad sequences are not harmless. + +### Dummy jobs + +When writing dummy jobs: + +- zero the midstate and merkle-residue content for the unused lanes + +Reason: + +- residual values can otherwise generate nonces that do not belong to the host + work model + +### Disabled TCEs + +If some TCEs are disabled: + +- software still has to maintain valid sequence structure +- disabled lanes should receive zeroed dummy content +- valid lanes can still carry production work + +### Partial write-job hazards + +If a write-job is incomplete: + +- missing bytes can be consumed from the next write +- unintended nonce generation can occur + +Do not treat UART framing problems as soft performance issues. They are +functional correctness problems. + +## TDM Overview + +TDM lets ASICs stream data back to the host in time slots associated with ASIC +IDs. + +The extracted datasheet text describes: + +- an ASIC owns TX opportunity when the slot matches its `ASIC_ID` +- the ASIC begins transmission after a programmed TDM delay +- TDM can carry multiple response classes including register responses, results, + `NOOP`, and thermal / voltage data + +The software guide gives a practical example: + +- with `128` bit-time slots at `5 MHz`, a TDM frame is approximately `2.5 ms` + +That matters because it bounds result and telemetry latency across a long chain. + +## Result Aggregation Model + +The software guide describes a two-stage buffering model: + +- each engine tile has an `8`-deep local result FIFO +- the ASIC notch block has a `16`-deep ASIC-level result FIFO + +Host-visible implications: + +- local tile FIFOs are scanned about every `300 us` +- results are aggregated before entering the TDM slot stream +- under expected mining conditions, FIFO overflow should be rare +- overflow bits still need to be handled because overflow means lost nonces + +## Sensor Streaming And Querying + +### TDM sensor behavior + +The datasheet describes thermal and voltage telemetry as a TDM payload source. +The voltage / thermal packet is described as the fourth packet class in TDM +operation. + +### Direct query behavior + +`DTS_VS` can also be used as a direct query opcode when explicit on-demand +sensor retrieval is needed. + +This is the model now exposed in the Rust debug CLI and HTTP API in this +repository. + +## DTS / VS Payload Layout + +The vendor material describes an 8-byte sensor payload. At a practical level, +the host needs to extract: + +- thermal tune code +- thermal validity / enable bits +- thermal-trip status +- voltage enable bit +- voltage shutdown / fault status +- voltage raw codes for `ch0`, `ch1`, and `ch2` +- PLL lock state bits when present in the response generation + +### Voltage channels + +The three channels represent: + +- `ch0`: bottom stack voltage +- `ch1`: top stack voltage +- `ch2`: differential between stacks + +These raw values should be converted into engineering units before being used +for protection or calibration decisions. + +## Sensor Conversion Equations + +### Temperature + +```text +T = K + Y * (N - 2^11 / 2^R) / 2^12 +``` + +Where: + +- `T` = Celsius +- `N` = raw tune code +- `R` = resolution +- `Y = 631.8` +- `K = -293.8` + +### Voltage + +```text +V = 1000 * (2 / 5) * VREF * (6 * N / 2^14 - 3 / 2^R - 1) +``` + +Where: + +- `V` = mV +- `N` = raw voltage code +- `R` = resolution +- `VREF = 0.7067` + +## `NOOP` Timing Caution + +The datasheet text includes a specific warning for `NOOP`: + +- do not issue back-to-back `NOOP` commands with only one stop bit of spacing +- in non-TDM mode, maintain at least a three-byte gap between consecutive + `NOOP`s + +Treat this as a real transport rule during low-level validation. + +## Practical Host Strategy + +For production-capable software, a good division of labor is: + +- use broadcast or multicast for common initialization +- use unicast for identity assignment and final trim +- use TDM for steady-state results and telemetry +- use direct `READREG`, `READRESULT`, `NOOP`, `LOOPBACK`, and `DTS_VS` for + debug and validation + +That is also how the Rust tooling in this repository is structured. + +## Practical Validation With This Repository + +Relevant follow-on references in this repository: + +- [ASIC Integration Guide](blockscale-asic-integration-guide.md) +- [BZM2 UART Debug Guide](bzm2-uart-debug.md) +- [BZM2 Port Note](bzm2-port.md) + +The Rust debug CLI already exposes: + +- `NOOP` scans +- loopback scans +- TDM watch mode +- direct `DTS_VS` queries +- result polling +- PLL and DLL diagnostics +- broadcast and multicast helpers +- silicon-validation flows diff --git a/docs/bzm2/bzm2-opcode-grounding.md b/docs/bzm2/bzm2-opcode-grounding.md new file mode 100644 index 00000000..463f01ce --- /dev/null +++ b/docs/bzm2/bzm2-opcode-grounding.md @@ -0,0 +1,80 @@ +# BZM2 Opcode And JTAG Grounding + +## Scope + +This note captures only behavior that is grounded in material available in this workspace: + +- top-level PDFs in `../docs` +- legacy UART implementation in [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h) and [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c) +- legacy exercised behavior in [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c) + +Anything not evidenced there is intentionally excluded from the Mujina port. + +## What The PDFs Proved + +The top-level PDFs are present: + +- the vendor JTAG usage guide +- the vendor opcode explanation guide + +From this shell, the PDFs are not extractable into reliable body text. The only recoverable textual evidence was: + +- JTAG PDF bookmark text: `PLL0/PLL1 debug signals` +- opcode PDF outline text: `1 Document Revision History` + +That is not enough to justify implementing a JTAG protocol layer or inventing undocumented opcode semantics. + +## What The Legacy Source Proved + +The legacy `bzmd` source gives a concrete UART wire contract for these opcodes: + +- `WRITEJOB` +- `READRESULT` +- `WRITEREG` +- `READREG` +- `MULTICAST_WRITE` +- `DTS_VS` +- `LOOPBACK` +- `NOOP` + +Grounded request/response behavior from [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c): + +- `WRITEREG`: request is `len(2 LE) + header(4 BE) + count_minus_one + payload` +- `MULTICAST_WRITE`: same framing as `WRITEREG`, but opcode `0x4` +- `READREG`: request is fixed-length `8` byte frame with terminal target byte; direct response is `asic + opcode + payload` +- `READRESULT`: in TDM mode, result frame is `asic + opcode + 8-byte payload` +- `NOOP`: request is a 4-byte frame; response payload is 3 bytes +- `LOOPBACK`: request is `len + header + count_minus_one + payload`; response echoes `asic + opcode + payload` +- `DTS_VS`: in TDM mode, payload is 4 bytes for gen1 and 8 bytes for gen2 + +Grounded concurrency and parser behavior from [uart.h](../bzm2_cgminer/feeds/mining_src/bzmd/uart.h), [uart.c](../bzm2_cgminer/feeds/mining_src/bzmd/uart.c), and [test.c](../bzm2_cgminer/feeds/mining_src/bzmd/tests/test.c): + +- TDM parsing is byte-stream oriented and must resynchronize after unknown prefixes +- TDM `READREG` response size is caller-driven and tracked per ASIC +- one outstanding TDM register read per ASIC is the supported model +- one outstanding TDM noop per ASIC is the supported model +- broadcast register writes use `WRITEREG` with ASIC `0xFF`, not a separate broadcast opcode +- broadcast TDM register reads are layered on top of `READREG`, not a distinct opcode + +## What Mujina Now Grounds + +Current Mujina BZM2 support in [protocol.rs](./mujina-miner/src/asic/bzm2/protocol.rs) is now explicitly locked to the legacy-tested UART behavior for: + +- `WRITEREG`, `READREG`, `WRITEJOB`, `MULTICAST_WRITE`, `READRESULT`, `NOOP`, `LOOPBACK`, `DTS_VS` +- gen1 and gen2 DTS/VS payload decoding +- partial-frame buffering and resynchronization after unknown byte prefixes +- legacy wire-format invariants for register, noop, and loopback command encoders + +## Deliberate Exclusions + +Not implemented from the docs side: + +- JTAG command transport +- JTAG IR/DR scan helpers +- PLL debug readout sequences +- any opcode semantics that cannot be traced to shipped UART code or tests + +Reason: + +- the available source in this workspace proves the UART mining/control path +- the available PDF extraction on this machine does not provide enough packet-level JTAG detail to implement anything defensible \ No newline at end of file diff --git a/docs/bzm2/bzm2-pnp.md b/docs/bzm2/bzm2-pnp.md new file mode 100644 index 00000000..18a76c52 --- /dev/null +++ b/docs/bzm2/bzm2-pnp.md @@ -0,0 +1,128 @@ +# BZM2 PnP Calibration In Mujina + +This note captures the current BZM2 PnP state in Mujina, what the legacy `bzmd` implementation did, and what is now implemented in the Rust port. + +## Current Gap + +Before this change, Mujina's BZM2 support had: + +- UART work dispatch +- result parsing +- thermal and power safety shutdowns +- UART register access +- PLL and DLL control + +What it did not have was a native Mujina tuning planner for BZM2: + +- operating-class and performance-mode target selection +- parameter sweep generation +- initial voltage and frequency selection from site temperature +- saved operating point reuse checks +- retune decisions when measured throughput regresses +- domain-aware planning for hardware with multiple voltage domains +- per-ASIC or per-stack frequency fine-tuning around a target pass-rate window + +## Legacy `pnp.c` Behavior + +The original C implementation mixed: + +- calibration search policy +- board and PSU policy +- persisted board calibration profiles +- per-ASIC telemetry accumulation +- per-engine pass-rate accounting +- platform-specific data collection and file I/O + +The reusable algorithmic parts are: + +- derive voltage, clock, and acceptance targets from operating class and performance mode +- derive initial voltage and clock from site thermal conditions +- broadcast a starting frequency +- sweep upward while respecting power and thermal guard rails +- tune back down on individual ASICs or stacks when pass rate falls outside the target window +- invalidate saved operating point when throughput regresses materially + +## Mujina Ported Behavior + +The new Rust module at +`mujina-miner/src/asic/bzm2/pnp.rs` +implements the reusable planner without pulling board-MCU or PSU glue into the ASIC layer. + +Implemented: + +- operating-class target tables for: + - generic + - EarlyValidation + - ProductionValidation + - StackTunedA + - StackTunedB + - ExtendedHeadroom + - ExtendedHeadroomB +- search-space generation corresponding to the historical C sweep helper +- site-temperature-aware initial voltage and clock planning corresponding to the historical C startup helper +- saved operating point reuse vs. full retune decisions +- domain-aware voltage planning using explicit voltage-domain offsets and guards +- per-domain frequency planning using aggregated pass-rate, thermal, and power data +- per-ASIC fine-tuning with optional per-stack / per-PLL behavior + +## Efficiency Model + +The planner is structured to scale cleanly from a single ASIC to large chains: + +- one pass to aggregate domain-level metrics +- one pass to emit per-domain plans +- one pass to emit per-ASIC adjustments + +That keeps the planning work effectively linear in ASIC count for normal use. + +For larger systems with multiple voltage domains, the planner prefers: + +- domain-level voltage decisions first +- domain-average frequency targets next +- per-ASIC or per-PLL corrections only where pass-rate or thermal data requires it + +That is materially more scalable than treating a 100-ASIC machine as 100 independent full-search problems. + +## Scope Boundary + +The planner is now wired into `Bzm2Board` startup so Mujina can: + +- execute a live pre-thread calibration phase +- persist applied calibration results as a saved operating point profile +- replay a compatible saved operating point profile directly on restart before falling back to retune +- collect live runtime tuning measurements during mining for: + - board throughput + - per-ASIC throughput + - per-ASIC average pass rate + - per-PLL throughput and pass rate + - per-domain measured rail voltage and power +- normalize saved-throughput comparisons and planned board hashrate against + actual active-engine capacity instead of assuming every ASIC still has the + default full map +- run the same planner against live runtime measurements during mining so the + board can continuously evaluate whether the current operating point is still + valid +- automatically promote saved operating point state from `pending` to + `validated` after clean runtime sampling +- automatically invalidate saved operating point profiles when persistent + runtime retune triggers fire, so restart replay will not reuse a known-bad + operating point + +Engine-capacity inputs now come from, in order: + +- live pre-calibration engine discovery when enabled +- saved per-ASIC topology embedded in the saved operating point profile +- default BZM2 hole-map fallback when no better topology data is available + +That means tuning decisions can now distinguish between: + +- a slow ASIC that still has full engine capacity +- an ASIC that is throughput-limited because it has permanently missing engines + +What still remains outside the ASIC planner layer: + +- board-specific PSU ramp policy +- reimplementation of the legacy CSV/database layer + +Those pieces still belong above the ASIC planner, in board or daemon integration layers. + diff --git a/docs/bzm2/bzm2-port.md b/docs/bzm2/bzm2-port.md new file mode 100644 index 00000000..60c49aac --- /dev/null +++ b/docs/bzm2/bzm2-port.md @@ -0,0 +1,371 @@ +# BZM2 Mujina Port + +## Architecture + +This port keeps BZM2 support inside Mujina rather than reviving the original split `cgminer` + `bzmd` process model. + +The legacy split looked like this: + +- `cgminer` handled scheduling, pool interaction, and IPC to `bzmd` +- `bzmd` owned UART transport, job fanout, result validation, and board-management glue + +In Mujina, those responsibilities map cleanly onto existing abstractions: + +- `Daemon` injects a virtual `bzm2` board when configured +- `Backplane` instantiates the virtual board through `inventory` +- `board::bzm2::Bzm2Board` opens serial transports and creates hash threads +- `asic::bzm2::Bzm2Thread` performs direct UART job dispatch, telemetry parsing, and share validation +- `asic::bzm2::control` provides reusable GPIO-reset and PMBus/I2C rail sequencing primitives + +A standalone Rust daemon is therefore not required for the mining path. + +## Bring-Up And Shutdown + +`Bzm2Board` now supports optional board-level power and reset sequencing through +the existing `Bzm2BringupPlan`. + +The current generic integration path uses file-backed adapters: + +- rail setpoint files for coarse voltage application +- optional rail enable files for regulator enable or precharge control +- an optional reset file for ASIC reset assertion and release + +This keeps the implementation generic across custom Linux-based carriers without +hard-coding one vendor board layout or management MCU protocol. + +## Implemented Behavior + +The BZM2 Mujina thread now reimplements the core legacy data path and the generally reusable portions of the control path: + +- 20 x 12 logical engine grid with the four excluded engines from legacy code +- enhanced-mode 4-midstate dispatch per logical engine +- version-rolling micro-jobs in slots `0, 2, 4, 8` +- UART register writes for target bits, leading-zero threshold, and timestamp count +- TDM result parsing with sequence parity matching +- nonce correction via enhanced-mode nonce gap +- in-thread Bitcoin header reconstruction and share validation before scheduler submission +- UART opcode coverage for: + - `WRITEJOB` + - `WRITEREG` + - `READREG` + - `MULTICAST_WRITE` + - `READRESULT` + - `NOOP` + - `LOOPBACK` + - `DTS_VS` +- DTS/VS generation 1 and generation 2 frame decoding +- live DTS/VS gen2 hardware-fault handling that shuts down the hash thread on thermal or voltage fault indications +- reusable GPIO reset-line control through `AsicEnable` +- reusable TPS546 PMBus rail control through `VoltageRegulator` +- reusable multi-rail bring-up and shutdown sequencing for single-rail, small-stack, and larger multi-stack designs +- UART-register-based PLL diagnostic/control flow for divider programming, enable/disable, lock polling, and readback +- UART-register-based DLL diagnostic/control flow for duty-cycle programming, enable/disable, lock polling, and fincon validation +- developer-facing UART debug CLI documented in [bzm2-uart-debug.md](bzm2-uart-debug.md) with unicast, multicast, and broadcast examples +- domain-aware BZM2 tuning planner documented in [bzm2-pnp.md](bzm2-pnp.md) for operating-class and performance-mode target selection, search-space generation, and per-domain plus per-ASIC tuning + +## Configuration + +Enable BZM2 by setting `MUJINA_BZM2_SERIAL` to one or more comma-separated serial device paths. + +Supported environment variables: + +- `MUJINA_BZM2_SERIAL`: required, comma-separated serial device paths +- `MUJINA_BZM2_SERIAL_PATHS`: alternate name for the same setting +- `MUJINA_BZM2_BAUD`: UART baud rate, default `5000000` +- `MUJINA_BZM2_TIMESTAMP_COUNT`: default `60` +- `MUJINA_BZM2_NONCE_GAP`: default `0x28` +- `MUJINA_BZM2_DISPATCH_MS`: redispatch interval in milliseconds, default `500` +- `MUJINA_BZM2_HASHRATE_THS`: nominal per-thread hashrate estimate, default `40` +- `MUJINA_BZM2_DTS_VS_GEN`: DTS/VS payload generation, `1` or `2`, default `2` +- `MUJINA_BZM2_ENUMERATE_CHAIN`: enable startup chain enumeration from the + documented default `ASIC_ID` +- `MUJINA_BZM2_AUTO_ENUMERATE`: alternate name for the same setting +- `MUJINA_BZM2_ENUM_START_ID`: first assigned runtime `ASIC_ID`, default `0` +- `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS`: comma-separated per-bus enumeration + ceilings, default `100` per bus unless calibration topology already provides + a larger configured count +- `MUJINA_BZM2_ENABLE_BRINGUP`: enable startup and shutdown rail/reset + sequencing +- `MUJINA_BZM2_BRINGUP_ENABLE`: alternate name for the same setting +- `MUJINA_BZM2_RAIL_SET_PATHS`: comma-separated rail-control file paths +- `MUJINA_BZM2_RAIL_TARGET_VOLTS`: comma-separated target rail voltages for the + bring-up plan +- `MUJINA_BZM2_RAIL_WRITE_SCALES`: optional comma-separated scale factors used + when converting volts into the raw file value, for example `1000` for mV or + `1000000` for uV +- `MUJINA_BZM2_DOMAIN_RAIL_INDICES`: optional comma-separated mapping from + planner domain id to configured rail index; defaults to one-to-one + `domain_id -> rail_index` when omitted +- `MUJINA_BZM2_RAIL_ENABLE_PATHS`: optional comma-separated enable/control file + paths paired with the rail list +- `MUJINA_BZM2_RAIL_ENABLE_VALUES`: optional comma-separated values written to + the rail enable paths during rail initialization +- `MUJINA_BZM2_RAIL_VIN_PATHS`: optional comma-separated rail input-voltage + sensor files +- `MUJINA_BZM2_RAIL_VIN_SCALES`: optional scale factors for the rail input + voltage files +- `MUJINA_BZM2_RAIL_VOUT_PATHS`: optional comma-separated rail output-voltage + sensor files +- `MUJINA_BZM2_RAIL_VOUT_SCALES`: optional scale factors for the rail output + voltage files +- `MUJINA_BZM2_RAIL_CURRENT_PATHS`: optional comma-separated rail current sensor + files +- `MUJINA_BZM2_RAIL_CURRENT_SCALES`: optional scale factors for the rail current + files +- `MUJINA_BZM2_RAIL_POWER_PATHS`: optional comma-separated rail power sensor + files +- `MUJINA_BZM2_RAIL_POWER_SCALES`: optional scale factors for the rail power + files +- `MUJINA_BZM2_RAIL_TEMP_PATHS`: optional comma-separated rail regulator + temperature sensor files +- `MUJINA_BZM2_RAIL_TEMP_SCALES`: optional scale factors for the rail + regulator temperature files +- `MUJINA_BZM2_RESET_PATH`: optional reset-control file path +- `MUJINA_BZM2_RESET_ACTIVE_LOW`: whether the reset path is active-low, default + `true` +- `MUJINA_BZM2_BRINGUP_PRE_POWER_MS`: delay before rail initialization, default + `10` +- `MUJINA_BZM2_BRINGUP_POST_POWER_MS`: delay after the configured rail steps, + default `25` +- `MUJINA_BZM2_BRINGUP_RELEASE_RESET_MS`: delay after reset release, default + `25` + +Startup enumeration notes: + +- this mode is intended for fresh chains where ASICs still answer on the + default `ASIC_ID` +- enumeration uses a bounded `NOOP` probe so the chain walk terminates cleanly + at the end of the bus +- if no default-id ASIC responds on startup, Mujina falls back to the configured + `MUJINA_BZM2_ASICS_PER_BUS` topology so warm-restart cases do not collapse to + zero ASICs + +Bring-up notes: + +- if bring-up is enabled, `Bzm2Board` applies the configured rail/reset + sequence before chain discovery, calibration, and hash-thread creation +- when the tuning planner produces per-domain voltage targets, `Bzm2Board` + now applies them onto the configured rail-control path before the PLL ramp + rather than treating them as advisory only +- saved operating-point replay now reapplies persisted per-domain voltages + before clock replay when the profile contains them +- if multiple domains are mapped onto one rail, the runtime requires them to + agree on the same target voltage; conflicting targets fail loudly instead of + applying an ambiguous setpoint +- on board shutdown, the same plan is used in reverse order to assert reset and + drive the configured rails back to `0` +- the current implementation is still coarse-grained at the regulator layer: + it applies domain targets onto configured rails, but it does not yet perform + closed-loop voltage verification against live rail telemetry or runtime retune + +If the optional rail telemetry files are configured, the board monitor also +publishes them into normal board state using stable names: + +- `rail0-input`, `rail1-input`, ... for input-side voltage snapshots +- `rail0-output`, `rail1-output`, ... for output-side voltage/current/power +- `rail0-regulator`, `rail1-regulator`, ... for regulator temperatures + +## API Telemetry + +When Gen2 `DTS_VS` frames are present on the UART path, Mujina now surfaces ASIC-internal telemetry through the normal board API state: + +- `BoardState.temperatures` +- `BoardState.powers` + +The values are named per serial bus and per ASIC so they can coexist with host-side sensor files: + +- temperature: `ttyUSB0-asic-2-dts` +- voltage channels: `ttyUSB0-asic-2-vs-ch0`, `ttyUSB0-asic-2-vs-ch1`, `ttyUSB0-asic-2-vs-ch2` + +Example JSON fragment: + +```json +{ + "temperatures": [ + { "name": "ttyUSB0-asic-2-dts", "temperature_c": 64.5 } + ], + "powers": [ + { "name": "ttyUSB0-asic-2-vs-ch0", "voltage_v": 0.78, "current_a": null, "power_w": null }, + { "name": "ttyUSB0-asic-2-vs-ch1", "voltage_v": 0.79, "current_a": null, "power_w": null }, + { "name": "ttyUSB0-asic-2-vs-ch2", "voltage_v": 0.77, "current_a": null, "power_w": null } + ] +} +``` + +Notes: + +- these ASIC-originated entries are merged into board state and do not replace host-file telemetry +- Celsius and voltage scaling follow the legacy `bzmd` DTS/VS conversion formulas +- Gen1 currently exposes voltage through this path, but not a Celsius temperature reading + +## On-Demand ASIC Sensor Queries + +Mujina now supports explicit DTS/VS query operations in addition to passive frame reporting. + +This is useful when: + +- the miner is idle and no passive DTS/VS traffic is arriving +- one ASIC is misbehaving and needs targeted inspection +- developers want a direct sensor read without enabling a full TDM watch session + +Two access paths are implemented: + +- CLI: `mujina-bzm2-debug dts-vs-query` and `mujina-bzm2-debug dts-vs-scan` +- HTTP API: `POST /api/v0/boards/{name}/bzm2/dts-vs-query` + +The query path runs through the live BZM2 hash-thread actor so UART ownership remains correct. Queried frames are converted through the same telemetry code path used for passive DTS/VS reporting, so the returned values land in normal `BoardState` telemetry. + +Example HTTP request: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +See [bzm2-uart-debug.md](bzm2-uart-debug.md) for CLI usage examples and expected output shape. + +## On-Demand Engine Discovery + +Mujina also supports explicit per-ASIC engine-map discovery when the thread is +idle. + +This is useful when: + +- an ASIC is returning unstable shares and the default engine-hole assumption is + no longer trustworthy +- developers need to compare the live engine map against the historical default + BZM2 hole pattern +- operators want the discovered topology recorded in normal board API state + +The engine-discovery path runs through the live BZM2 hash-thread actor, just +like the DTS/VS query path, so UART ownership stays correct. Successful scans +update the live thread engine layout and `BoardState.asics` with: + +- `id` +- `thread_index` +- `serial_path` +- `discovered_engine_count` +- `missing_engines` + +After a successful idle-time scan, subsequent work dispatch and result +reconstruction use the discovered active-engine layout instead of the built-in +default four-hole map. + +HTTP API: + +- `POST /api/v0/boards/{name}/bzm2/discover-engines` + +Example HTTP request: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/discover-engines \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"tdm_prediv_raw":15,"tdm_counter":16,"timeout_ms":150}' +``` + +The response returns the refreshed `BoardState`, including the updated +`asics` topology entry for the queried ASIC. + +## API Diagnostics + +The board/API surface now exposes a first live UART diagnostics slice through +the BZM2 thread actor: + +- `POST /api/v0/boards/{name}/bzm2/noop` +- `POST /api/v0/boards/{name}/bzm2/loopback` +- `POST /api/v0/boards/{name}/bzm2/register-read` +- `POST /api/v0/boards/{name}/bzm2/register-write` +- `POST /api/v0/boards/{name}/bzm2/clock-report` + +These commands intentionally route through the live board-owned thread instead +of opening a second serial handle. That keeps UART ownership correct and avoids +silent contention with the mining path. + +Current safety boundary: + +- the target thread must be idle +- DTS/VS streaming must be inactive on that thread + +If either condition is false, the command is rejected rather than racing active +mining traffic or background telemetry frames. + +`clock-report` returns the same PLL/DLL status surface already exposed by the +standalone Rust debug CLI: + +- PLL enable register +- PLL misc register +- PLL enabled/locked bits +- DLL control2/control5 values +- DLL `coarsecon` +- DLL `fincon` +- DLL freeze-valid, lock, and fincon-valid state + +## Chain Summary + +The board/API surface also exposes the current BZM2 chain layout without +opening a second serial handle: + +- `GET /api/v0/boards/{name}/bzm2/chain-summary` + +The response summarizes: + +- current UART bus count +- serial path per bus +- global ASIC start/count per bus +- total ASIC count across the board +- whether the active operating point came from saved replay or live calibration +- current saved operating point validation state + +That gives operators a stable summary view of the active chain layout even when +the underlying board was discovered by startup enumeration rather than static +configuration alone. + +## Design Boundary + +The legacy `bzmd` board-power path mixes three different concerns: + +- genuinely reusable sequencing concepts +- generic peripheral protocols like PMBus/I2C regulators and reset GPIOs +- highly board-specific MCU command sets, sysfs GPIO numbering, CAN PSU control, and platform wiring assumptions + +Only the first two belong in a generally applicable Mujina BZM2 implementation. + +Ported into Mujina: + +- generic reset assertion/deassertion +- generic ordered rail bring-up and shutdown +- generic PMBus/TPS546 voltage control and telemetry adapters +- ASIC-originated DTS/VS telemetry and fault handling + +Intentionally not ported verbatim: + +- Intel board MCU command protocol from `mcu.c` +- hard-coded board GPIO numbering and sysfs reset pulses from `util.c` / `daemon.c` +- platform CAN PSU control from `psu.c` +- board-specific fan and ambient-sensor plumbing that depends on the original platform layout + +Those pieces should only be added behind a concrete Mujina board implementation when the target hardware actually uses them. + +## Current Limits + +Still not implemented from the broader legacy stack: + +- JTAG workflows from the standalone platform documents +- JTAG-only PLL debug sequences that are not represented in the shipped UART code +- calibration and autotuning state machines +- full manufacturing and diagnostics RPC parity + - beyond the current live API surface for: + - `NOOP` + - loopback + - register read/write + - clock report + - chain summary +- any board-MCU protocol that is specific to one carrier or backplane design + +The top-level `docs` PDFs reference additional JTAG and opcode material, but this port currently implements the opcode surface that is evidenced in the legacy shipping UART path and not an inferred JTAG control plane. + + +See also: + +- [bzm2-opcode-grounding.md](bzm2-opcode-grounding.md) for the source-grounded opcode matrix and the current JTAG evidence boundary diff --git a/docs/bzm2/bzm2-uart-debug.md b/docs/bzm2/bzm2-uart-debug.md new file mode 100644 index 00000000..ff8696e3 --- /dev/null +++ b/docs/bzm2/bzm2-uart-debug.md @@ -0,0 +1,449 @@ +# BZM2 UART Debug Guide + +This guide documents the direct BZM2 UART and silicon-validation interface added to Mujina. + +## Intent + +The current CLI folds in the portable parts of the legacy silicon validation surface: + +- chain enumeration and `ASIC_ID` assignment from the default bus id +- ASIC discovery and liveness scans +- loopback data-path validation +- explicit TDM enable and disable control +- live TDM result, register, noop, and DTS/VS observation +- broadcast TDM register-read observation across many ASICs +- engine presence probing and full physical engine-map discovery +- engine-wide timestamp, target, and leading-zero programming +- deterministic grid-job exercisers for single-phase and back-to-back job dispatch +- existing PLL and DLL diagnostics and bring-up helpers + +What is deliberately not ported here: + +- legacy board-reset and board-count orchestration +- BCH-vector and nonce-golden-data regression harnesses +- opaque manufacturing hooks and carrier-specific glue + +## Routing Modes + +- unicast: one ASIC, one destination address +- broadcast: all ASICs on the UART bus via ASIC id `0xff` +- multicast: one ASIC, one engine-row group + +## Binary + +Use [bzm2-debug.rs](../mujina-miner/src/bin/bzm2-debug.rs) through Cargo: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- ... +``` + +## Legacy Test Mapping + +The most useful silicon validation tests map to the following commands: + +- `test_noop_all_asic` -> `noop-scan` +- `test_loopback` -> `loopback-scan` +- `test_effbst_tdm_selectable_leadingzeros` -> `engine-zeros-all` plus `job-grid-watch` +- `test_effbst_tdm_jobsubmit_with_writejobcmd` -> `job-grid` or `job-grid-watch` +- `test_effbst_tdm_continuous_b2b_jobs` -> `job-grid-2phase-watch` +- `uart_command_broadcast_readreg_tdm_async` flows -> `tdm-broadcast-read-watch` +- general TDM callback validation -> `tdm-watch` +- historical engine-detect flow -> `engine-probe` or `discover-engine-map` + +The old multicasted-EFFBST and auto-clock-gating tests depended on the legacy BCH vector library and runtime board harness. The new CLI does not claim golden nonce validation there; it provides deterministic packet generation and live observation so developers can still exercise the same ASIC paths when debugging hardware. + +## Direct UART Examples + +Read one ASIC-local register: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-read /dev/ttyUSB0 2 notch 0x12 4 5000000 +``` + +Write one ASIC-local register: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 +``` + +Run a NOOP sanity check across an ASIC range: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + noop-scan /dev/ttyUSB0 0 15 5000000 +``` + +Enumerate a powered chain from the default `ASIC_ID` (`0xFA`) and assign +incrementing ids starting at `0`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + enumerate-chain /dev/ttyUSB0 32 0 5000000 +``` + +This is the first generic bring-up step for a fresh chain where every ASIC is +still on the default id. + +The chain walk now uses a bounded `NOOP` probe internally, so the command stops +cleanly when no additional default-id ASIC is present instead of hanging at the +end of the bus. + +The same discovery flow can be enabled during Mujina board startup with: + +- `MUJINA_BZM2_ENUMERATE_CHAIN=true` +- optional `MUJINA_BZM2_ENUM_START_ID` +- optional `MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS` + +Run deterministic loopback validation across an ASIC range: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + loopback-scan /dev/ttyUSB0 0 15 8 5000000 +``` + +Read one synchronous result frame from one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-read-result /dev/ttyUSB0 2 gen2 5000000 +``` + +## API Visibility + +Passive Gen2 `DTS_VS` traffic is now reflected in the board API as named ASIC telemetry. + +Expected sensor names: + +- temperature: `ttyUSB0-asic-2-dts` +- voltage: `ttyUSB0-asic-2-vs-ch0`, `ttyUSB0-asic-2-vs-ch1`, `ttyUSB0-asic-2-vs-ch2` + +This is board-state telemetry, not a separate debug-only channel. If the miner is already receiving `DTS_VS` frames during operation, these entries appear in the normal board JSON under: + +- `temperatures` +- `powers` + +Use `tdm-watch` when you need to correlate the raw UART frames with the API-visible values. + +## On-Demand DTS/VS Queries + +Passive telemetry is useful when the miner is already receiving `DTS_VS` traffic. When hardware is misbehaving, you often need an explicit query path that forces a fresh ASIC sensor read. + +The debug CLI now supports direct on-demand DTS/VS reads: + +Query one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-query /dev/ttyUSB0 2 gen2 1500 5000000 +``` + +Scan an ASIC range and print each returned DTS/VS frame: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dts-vs-scan /dev/ttyUSB0 0 15 gen2 1500 5000000 +``` + +These commands: + +- enable the DTS/VS path if it is not already configured +- wait for a matching DTS/VS frame from the requested ASIC +- print temperature and voltage information in engineering units +- preserve the normal passive telemetry path so the same readings can still appear in board state later + +The `dts-gen` argument should match the ASIC telemetry generation in use: + +- `gen1` +- `gen2` + +Typical output includes: + +- ASIC id +- thermal status and trip bits +- Celsius temperature when available from the generation-specific decode path +- voltage channels in volts + +## HTTP API Query Example + +Mujina also exposes the same operation through the board API for live boards: + +```text +POST /api/v0/boards/{name}/bzm2/dts-vs-query +``` + +Example: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/dts-vs-query \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +The response is the refreshed board JSON after the query completes. The relevant values appear in the normal board-state telemetry collections, for example: + +```json +{ + "temperatures": [ + { "name": "ttyUSB0-asic-2-dts", "temperature_c": 64.5 } + ], + "powers": [ + { "name": "ttyUSB0-asic-2-vs-ch0", "voltage_v": 0.78, "current_a": null, "power_w": null } + ] +} +``` + +Request fields: + +- `thread_index`: which BZM2 hash thread owns the target UART bus +- `asic`: ASIC id to query on that bus + +## HTTP API Diagnostics + +Mujina now exposes a first board/API parity slice for live UART diagnostics +without dropping to a separate serial tool. These operations route through the +live BZM2 thread actor, so they preserve UART ownership and operate against the +same board instance visible in the normal API. + +Available endpoints: + +- `POST /api/v0/boards/{name}/bzm2/noop` +- `POST /api/v0/boards/{name}/bzm2/loopback` +- `POST /api/v0/boards/{name}/bzm2/register-read` +- `POST /api/v0/boards/{name}/bzm2/register-write` +- `POST /api/v0/boards/{name}/bzm2/clock-report` + +Examples: + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/noop \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/loopback \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"payload_hex":"0102aabb"}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-read \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"count":4}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/register-write \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2,"engine_address":4095,"offset":18,"value_hex":"deadbeef"}' +``` + +```bash +curl -X POST http://127.0.0.1:3000/api/v0/boards/bzm2-0/bzm2/clock-report \ + -H "Content-Type: application/json" \ + -d '{"thread_index":0,"asic":2}' +``` + +Representative responses: + +```json +{ "payload_hex": "425a32" } +``` + +```json +{ "payload_hex": "0102aabb" } +``` + +```json +{ "value_hex": "11223344" } +``` + +```json +{ "bytes_written": 4 } +``` + +Current safety boundary: + +- these live UART diagnostics require the target BZM2 thread to be idle +- they also require DTS/VS streaming to be inactive on that thread +- if those conditions are not met, the board command returns an error instead + of competing with active mining or background telemetry traffic + +## TDM Control and Observation + +The legacy `enable_tdm()` path is grounded in a local-register write to `LOCAL_REG_UART_TDM_CTL` (`0x07`) with control word: + +```text +(prediv_raw << 9) | (tdm_counter << 1) | enable_bit +``` + +Enable TDM explicitly: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + tdm-enable /dev/ttyUSB0 0x12 16 5000000 +``` + +Disable TDM explicitly: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + tdm-disable /dev/ttyUSB0 0x12 16 5000000 +``` + +Watch the mixed TDM stream for five seconds: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + tdm-watch /dev/ttyUSB0 gen2 5 5000000 +``` + +Broadcast a register read and collect returned TDM read frames from ASICs `0` through `15`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + tdm-broadcast-read-watch /dev/ttyUSB0 gen2 0 15 notch 0x12 4 5000000 +``` + +Probe one physical engine coordinate using the historical TDM-sync +`ENGINE_REG_END_NONCE` detection rule: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-probe /dev/ttyUSB0 2 0 0 0x0f 16 100 5000000 +``` + +Scan the full `20 x 12` physical grid and report the discovered hole map for +one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + discover-engine-map /dev/ttyUSB0 2 0x0f 16 100 5000000 +``` + +Notes: + +- these commands intentionally scan all physical coordinates, not the legacy + default engine list +- discovery uses the source-grounded rule from the historical C path: + `ENGINE_REG_END_NONCE == 0xfffffffe` means the engine is present +- the CLI reports whether the discovered missing coordinates still match the + default four-hole BZM2 map or whether the ASIC diverges from that assumption + +## Engine-Wide Programming Helpers + +Set the target register across every engine row on one ASIC or the whole bus: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-target-all /dev/ttyUSB0 2 0x1705ffff 5000000 +``` + +Set timestamp count across all engine rows: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-timestamp-all /dev/ttyUSB0 2 60 5000000 +``` + +Program selectable leading zeros across all engine rows: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + engine-zeros-all /dev/ttyUSB0 2 48 5000000 +``` + +`engine-zeros-all` accepts `32..=64`, matching the legacy validation flow. The actual register value written is `zeros_to_find - 32`, which is what the C source used. + +## Job Exercisers + +These commands intentionally generate deterministic synthetic job material. They are designed to exercise the ASIC job path and TDM-result machinery without pretending to replace the legacy golden-vector regression harness. + +Dispatch one full grid of `WRITEJOB` packets: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid /dev/ttyUSB0 2 0 1700000000 60 5000000 +``` + +Dispatch one full grid and watch live TDM responses: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 +``` + +Dispatch back-to-back grids with the second phase offset by the legacy `MAX_EFFBST_SUBJOBS` sequence spacing: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + job-grid-2phase-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2 5000000 +``` + +## Broadcast, Multicast, and Unicast Reference + +Use unicast when you need one ASIC-local or one engine-local change: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 2 notch 0x12 01000000 5000000 +``` + +Use broadcast when every ASIC on the bus should receive the same packet: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000 5000000 +``` + +Use multicast when one ASIC needs the same engine-register update across a full row group: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + uart-multicast-write /dev/ttyUSB0 2 7 0x48 3c 5000000 +``` + +The higher-level helpers such as `engine-timestamp-all`, `engine-zeros-all`, and `job-grid` are implemented on top of this same routing model so developers can either stay at the helper level or drop down to raw packet control when needed. + +## Clock Diagnostics + +Read PLL and DLL status for one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + clock-report /dev/ttyUSB0 2 5000000 +``` + +Program and lock one PLL on one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + pll-set /dev/ttyUSB0 2 pll1 625 0 5000000 +``` + +Program, enable, and validate one DLL on one ASIC: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dll-set /dev/ttyUSB0 2 dll1 55 5000000 +``` + +Broadcast one PLL program and verify lock across ASIC ids `0` through `15`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + pll-broadcast-lock-check /dev/ttyUSB0 pll0 625 0 0 15 5000000 +``` + +Broadcast one DLL program and verify lock and `fincon` validity across ASIC ids `0` through `15`: + +```text +cargo run -p mujina-miner --bin mujina-bzm2-debug -- \ + dll-broadcast-lock-check /dev/ttyUSB0 dll0 50 0 15 5000000 +``` + +## Scope Boundary + +This interface is grounded in the legacy shipped UART path and the silicon validation source. It is not a JTAG debug interface and it is not a full BCH-vector regression harness. diff --git a/mujina-miner/Cargo.toml b/mujina-miner/Cargo.toml index b3520e0b..4deaf8ff 100644 --- a/mujina-miner/Cargo.toml +++ b/mujina-miner/Cargo.toml @@ -64,6 +64,10 @@ path = "src/bin/cli.rs" name = "mujina-tui" path = "src/bin/tui.rs" + +[[bin]] +name = "mujina-bzm2-debug" +path = "src/bin/bzm2-debug.rs" [features] default = [] skip-pty-tests = [] # Skip PTY-based serial tests that may hang in some environments diff --git a/mujina-miner/src/api/registry.rs b/mujina-miner/src/api/registry.rs index 1d85784c..ddf12208 100644 --- a/mujina-miner/src/api/registry.rs +++ b/mujina-miner/src/api/registry.rs @@ -1,7 +1,9 @@ //! Dynamic board registration tracking. +use tokio::sync::mpsc; + use crate::api_client::types::BoardState; -use crate::board::BoardRegistration; +use crate::board::{BoardCommand, BoardRegistration}; /// Dynamic collection of board registrations. /// @@ -23,22 +25,44 @@ impl BoardRegistry { self.boards.push(reg); } + fn prune_disconnected(&mut self) { + self.boards.retain(|reg| reg.state_rx.has_changed().is_ok()); + } + /// Snapshot all connected boards. /// /// Removes boards whose sender has been dropped (board disconnected) /// and returns the current state of each. pub fn boards(&mut self) -> Vec { - self.boards.retain(|reg| reg.state_rx.has_changed().is_ok()); + self.prune_disconnected(); self.boards .iter() .map(|reg| reg.state_rx.borrow().clone()) .collect() } + + /// Snapshot one connected board by name. + pub fn board(&mut self, name: &str) -> Option { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.state_rx.borrow().name == name) + .map(|reg| reg.state_rx.borrow().clone()) + } + + /// Clone a board command sender by board name if available. + pub fn command_tx(&mut self, name: &str) -> Option> { + self.prune_disconnected(); + self.boards + .iter() + .find(|reg| reg.state_rx.borrow().name == name) + .and_then(|reg| reg.command_tx.clone()) + } } #[cfg(test)] mod tests { - use tokio::sync::watch; + use tokio::sync::{mpsc, watch}; use super::*; use crate::board::BoardRegistration; @@ -52,7 +76,13 @@ mod tests { ..Default::default() }; let (tx, rx) = watch::channel(state); - (tx, BoardRegistration { state_rx: rx }) + ( + tx, + BoardRegistration { + state_rx: rx, + command_tx: None, + }, + ) } #[test] @@ -79,16 +109,13 @@ mod tests { registry.push(reg_a); registry.push(reg_b); - // Both present initially assert_eq!(registry.boards().len(), 2); - // Drop the sender for board B -- simulates board disconnect drop(drop_me); let boards = registry.boards(); assert_eq!(boards.len(), 1); assert_eq!(boards[0].name, "stays"); - // Sender A still alive drop(keep); } @@ -104,4 +131,22 @@ mod tests { tx.send_modify(|s| s.model = "Updated".into()); assert_eq!(registry.boards()[0].model, "Updated"); } + + #[test] + fn returns_command_sender_for_named_board() { + let mut registry = BoardRegistry::new(); + let (_state_tx, state_rx) = watch::channel(BoardState { + name: "board-a".into(), + model: "Test".into(), + ..Default::default() + }); + let (command_tx, _command_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(command_tx.clone()), + }); + + let cloned = registry.command_tx("board-a"); + assert!(cloned.is_some()); + } } diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index 874b6e8f..6110a02a 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -136,8 +136,13 @@ mod tests { use super::*; use crate::api::commands::SchedulerCommand; - use crate::api_client::types::{BoardState, SourceState}; - use crate::board::BoardRegistration; + use crate::api_client::types::{ + AsicState, BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, + Bzm2LoopbackRequest, Bzm2LoopbackResponse, Bzm2NoopRequest, Bzm2NoopResponse, + Bzm2RegisterReadRequest, Bzm2RegisterReadResponse, Bzm2RegisterWriteRequest, + Bzm2RegisterWriteResponse, EngineCoordinate, SourceState, TemperatureSensor, + }; + use crate::board::{BoardCommand, BoardRegistration}; /// Test fixtures returned by the router builder. struct TestFixtures { @@ -158,7 +163,10 @@ mod tests { let mut board_senders = Vec::new(); for state in board_states { let (tx, rx) = watch::channel(state); - registry.push(BoardRegistration { state_rx: rx }); + registry.push(BoardRegistration { + state_rx: rx, + command_tx: None, + }); board_senders.push(tx); } @@ -181,6 +189,23 @@ mod tests { (status, String::from_utf8(body.to_vec()).unwrap()) } + async fn post_json( + app: Router, + uri: &str, + body: &T, + ) -> (http::StatusCode, String) { + let req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(axum::body::Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(body.to_vec()).unwrap()) + } + #[tokio::test] async fn health_returns_ok() { let fixtures = build_test_router(MinerState::default(), vec![]); @@ -272,6 +297,278 @@ mod tests { assert_eq!(status, 404); } + #[tokio::test] + async fn bzm2_query_endpoint_returns_refreshed_board_state() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let state_tx_for_command = state_tx.clone(); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + if let Some(BoardCommand::QueryBzm2DtsVs { + thread_index, + asic, + reply, + }) = board_cmd_rx.recv().await + { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + state_tx_for_command.send_modify(|state| { + state.temperatures.push(TemperatureSensor { + name: "ttyUSB0-asic-2-dts".into(), + temperature_c: Some(64.5), + }); + }); + let _ = reply.send(Ok(())); + } + }); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/dts-vs-query", + &Bzm2DtsVsQueryRequest { + thread_index: 0, + asic: 2, + }, + ) + .await; + + assert_eq!(status, 200); + let board: BoardState = serde_json::from_str(&body).unwrap(); + assert!(board.temperatures.iter().any( + |sensor| sensor.name == "ttyUSB0-asic-2-dts" && sensor.temperature_c == Some(64.5) + )); + drop(miner_tx); + drop(cmd_rx); + } + + #[tokio::test] + async fn bzm2_diagnostic_endpoints_round_trip_payloads() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (_state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(4); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + while let Some(command) = board_cmd_rx.recv().await { + match command { + BoardCommand::QueryBzm2Noop { + thread_index, + asic, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + let _ = reply.send(Ok(*b"BZ2")); + } + BoardCommand::QueryBzm2Loopback { + thread_index, + asic, + payload, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(payload, vec![0x01, 0x02, 0xaa, 0xbb]); + let _ = reply.send(Ok(payload)); + } + BoardCommand::ReadBzm2Register { + thread_index, + asic, + engine_address, + offset, + count, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(engine_address, 0x0fff); + assert_eq!(offset, 0x12); + assert_eq!(count, 4); + let _ = reply.send(Ok(vec![0x11, 0x22, 0x33, 0x44])); + } + BoardCommand::WriteBzm2Register { + thread_index, + asic, + engine_address, + offset, + value, + reply, + } => { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(engine_address, 0x0fff); + assert_eq!(offset, 0x12); + assert_eq!(value, vec![0xde, 0xad, 0xbe, 0xef]); + let _ = reply.send(Ok(())); + } + _ => {} + } + } + }); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/noop", + &Bzm2NoopRequest { + thread_index: 0, + asic: 2, + }, + ) + .await; + assert_eq!(status, 200); + let noop: Bzm2NoopResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(noop.payload_hex, "425a32"); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/loopback", + &Bzm2LoopbackRequest { + thread_index: 0, + asic: 2, + payload_hex: "0102aabb".into(), + }, + ) + .await; + assert_eq!(status, 200); + let loopback: Bzm2LoopbackResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(loopback.payload_hex, "0102aabb"); + + let (status, body) = post_json( + router.clone(), + "/api/v0/boards/bzm2-test/bzm2/register-read", + &Bzm2RegisterReadRequest { + thread_index: 0, + asic: 2, + engine_address: 0x0fff, + offset: 0x12, + count: 4, + }, + ) + .await; + assert_eq!(status, 200); + let readback: Bzm2RegisterReadResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(readback.value_hex, "11223344"); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/register-write", + &Bzm2RegisterWriteRequest { + thread_index: 0, + asic: 2, + engine_address: 0x0fff, + offset: 0x12, + value_hex: "deadbeef".into(), + }, + ) + .await; + assert_eq!(status, 200); + let write_ack: Bzm2RegisterWriteResponse = serde_json::from_str(&body).unwrap(); + assert_eq!(write_ack.bytes_written, 4); + + drop(miner_tx); + drop(cmd_rx); + } + + #[tokio::test] + async fn bzm2_engine_discovery_endpoint_returns_refreshed_board_state() { + let (miner_tx, miner_rx) = watch::channel(MinerState::default()); + let (cmd_tx, cmd_rx) = mpsc::channel::(16); + let mut registry = BoardRegistry::new(); + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + ..Default::default() + }); + let state_tx_for_command = state_tx.clone(); + let (board_cmd_tx, mut board_cmd_rx) = mpsc::channel(1); + registry.push(BoardRegistration { + state_rx, + command_tx: Some(board_cmd_tx), + }); + let router = build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx); + + tokio::spawn(async move { + if let Some(BoardCommand::DiscoverBzm2Engines { + thread_index, + asic, + tdm_prediv_raw, + tdm_counter, + timeout_ms, + reply, + }) = board_cmd_rx.recv().await + { + assert_eq!(thread_index, 0); + assert_eq!(asic, 2); + assert_eq!(tdm_prediv_raw, 0x0f); + assert_eq!(tdm_counter, 16); + assert_eq!(timeout_ms, Some(150)); + state_tx_for_command.send_modify(|state| { + state.asics.push(AsicState { + id: 2, + thread_index: Some(0), + serial_path: Some("/dev/ttyUSB0".into()), + discovered_engine_count: Some(236), + missing_engines: vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ], + }); + }); + let _ = reply.send(Ok(())); + } + }); + + let (status, body) = post_json( + router, + "/api/v0/boards/bzm2-test/bzm2/discover-engines", + &Bzm2EngineDiscoveryRequest { + thread_index: 0, + asic: 2, + tdm_prediv_raw: 0x0f, + tdm_counter: 16, + timeout_ms: Some(150), + }, + ) + .await; + + assert_eq!(status, 200); + let board: BoardState = serde_json::from_str(&body).unwrap(); + assert!(board.asics.iter().any(|asic| { + asic.id == 2 + && asic.thread_index == Some(0) + && asic.discovered_engine_count == Some(236) + && asic.missing_engines + == vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ] + })); + drop(miner_tx); + drop(cmd_rx); + } + #[tokio::test] async fn sources_returns_list() { let miner_state = MinerState { diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 079b5833..feb9b3fc 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -15,7 +15,13 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; -use crate::api_client::types::{BoardState, MinerPatchRequest, MinerState, SourceState}; +use crate::api_client::types::{ + BoardState, Bzm2DtsVsQueryRequest, Bzm2EngineDiscoveryRequest, Bzm2LoopbackRequest, + Bzm2LoopbackResponse, Bzm2NoopRequest, Bzm2NoopResponse, Bzm2RegisterReadRequest, + Bzm2RegisterReadResponse, Bzm2RegisterWriteRequest, Bzm2RegisterWriteResponse, + MinerPatchRequest, MinerState, SourceState, +}; +use crate::board::BoardCommand; /// Build the v0 API routes with OpenAPI metadata. pub fn routes() -> OpenApiRouter { @@ -24,6 +30,12 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_miner, patch_miner)) .routes(routes!(get_boards)) .routes(routes!(get_board)) + .routes(routes!(query_bzm2_dts_vs)) + .routes(routes!(query_bzm2_noop)) + .routes(routes!(query_bzm2_loopback)) + .routes(routes!(read_bzm2_register)) + .routes(routes!(write_bzm2_register)) + .routes(routes!(discover_bzm2_engines)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -137,6 +149,347 @@ async fn get_board( .ok_or(StatusCode::NOT_FOUND) } +/// Trigger an explicit BZM2 DTS/VS query and return the refreshed board state. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/dts-vs-query", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2DtsVsQueryRequest, + responses( + (status = OK, description = "Refreshed board details", body = BoardState), + (status = BAD_REQUEST, description = "Board does not support BZM2 telemetry queries"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_dts_vs( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::QueryBzm2DtsVs { + thread_index: req.thread_index, + asic: req.asic, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .board(&name) + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +fn decode_hex_payload(raw: &str) -> Result, StatusCode> { + hex::decode(raw.trim()).map_err(|_| StatusCode::BAD_REQUEST) +} + +/// Trigger a live BZM2 NOOP diagnostic through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/noop", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2NoopRequest, + responses( + (status = OK, description = "NOOP response payload", body = Bzm2NoopResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_noop( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::QueryBzm2Noop { + thread_index: req.thread_index, + asic: req.asic, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(payload))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2NoopResponse { + payload_hex: hex::encode(payload), + })) +} + +/// Trigger a live BZM2 loopback diagnostic through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/loopback", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2LoopbackRequest, + responses( + (status = OK, description = "Loopback response payload", body = Bzm2LoopbackResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics or request payload is invalid"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn query_bzm2_loopback( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let payload = decode_hex_payload(&req.payload_hex)?; + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::QueryBzm2Loopback { + thread_index: req.thread_index, + asic: req.asic, + payload, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(payload))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2LoopbackResponse { + payload_hex: hex::encode(payload), + })) +} + +/// Perform a live BZM2 register read through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/register-read", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2RegisterReadRequest, + responses( + (status = OK, description = "Register payload", body = Bzm2RegisterReadResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn read_bzm2_register( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::ReadBzm2Register { + thread_index: req.thread_index, + asic: req.asic, + engine_address: req.engine_address, + offset: req.offset, + count: req.count, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(value))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2RegisterReadResponse { + value_hex: hex::encode(value), + })) +} + +/// Perform a live BZM2 register write through a board-owned UART thread. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/register-write", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2RegisterWriteRequest, + responses( + (status = OK, description = "Register write acknowledgement", body = Bzm2RegisterWriteResponse), + (status = BAD_REQUEST, description = "Board does not support BZM2 diagnostics or request payload is invalid"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn write_bzm2_register( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let value = decode_hex_payload(&req.value_hex)?; + let bytes_written = value.len(); + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::WriteBzm2Register { + thread_index: req.thread_index, + asic: req.asic, + engine_address: req.engine_address, + offset: req.offset, + value, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(Json(Bzm2RegisterWriteResponse { bytes_written })) +} + +/// Trigger an explicit BZM2 engine-discovery scan and return the refreshed board state. +#[utoipa::path( + post, + path = "/boards/{name}/bzm2/discover-engines", + tag = "boards", + params( + ("name" = String, Path, description = "Board name"), + ), + request_body = Bzm2EngineDiscoveryRequest, + responses( + (status = OK, description = "Refreshed board details", body = BoardState), + (status = BAD_REQUEST, description = "Board does not support BZM2 engine discovery"), + (status = NOT_FOUND, description = "Board not found"), + (status = INTERNAL_SERVER_ERROR, description = "Board command failed"), + ), +)] +async fn discover_bzm2_engines( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, StatusCode> { + let (board_exists, command_tx) = { + let mut registry = state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()); + (registry.board(&name).is_some(), registry.command_tx(&name)) + }; + if !board_exists { + return Err(StatusCode::NOT_FOUND); + } + let Some(command_tx) = command_tx else { + return Err(StatusCode::BAD_REQUEST); + }; + + let (tx, rx) = oneshot::channel(); + command_tx + .send(BoardCommand::DiscoverBzm2Engines { + thread_index: req.thread_index, + asic: req.asic, + tdm_prediv_raw: req.tdm_prediv_raw, + tdm_counter: req.tdm_counter, + timeout_ms: req.timeout_ms, + reply: tx, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + state + .board_registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .board(&name) + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + /// Return all registered job sources. #[utoipa::path( get, diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 9a71be5d..56e74a61 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -31,6 +31,10 @@ pub struct BoardState { pub temperatures: Vec, pub powers: Vec, pub threads: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub bzm2_tuning: Option, } /// Fan status. @@ -70,6 +74,82 @@ pub struct ThreadState { pub is_active: bool, } +/// Per-ASIC runtime topology or diagnostics state. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct AsicState { + pub id: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub discovered_engine_count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_engines: Vec, +} + +/// BZM2 runtime tuning measurements derived from live mining operation. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct Bzm2TuningState { + #[serde(skip_serializing_if = "Option::is_none")] + pub board_throughput_hs: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub domains: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asics: Vec, +} + +/// Per-domain live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2DomainTuningState { + pub domain_id: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub rail_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_voltage_mv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured_voltage_mv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured_power_w: Option, +} + +/// Per-PLL live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2PllTuningState { + pub pll_index: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_mhz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput_hs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pass_rate: Option, +} + +/// Per-ASIC live tuning measurement. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2AsicTuningState { + pub id: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_engine_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput_hs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub average_pass_rate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduler_share_count: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plls: Vec, +} + +/// Physical engine coordinate on one ASIC. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema, PartialEq, Eq)] +pub struct EngineCoordinate { + pub row: u8, + pub col: u8, +} + /// Writable fields for `PATCH /api/v0/miner`. /// /// All fields are optional; only those present in the request body are @@ -88,6 +168,109 @@ pub struct SetFanTargetRequest { pub target_percent: Option, } +/// Request body for an explicit BZM2 ASIC DTS/VS query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2DtsVsQueryRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, +} + +/// Request body for an explicit BZM2 ASIC engine-discovery scan. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2EngineDiscoveryRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Raw TDM pre-divider value written into `LOCAL_REG_UART_TDM_CTL`. + pub tdm_prediv_raw: u32, + /// TDM counter value written into `LOCAL_REG_UART_TDM_CTL`. + pub tdm_counter: u8, + /// Optional per-engine probe timeout in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +/// Request body for a live BZM2 NOOP diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2NoopRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, +} + +/// Response body for a live BZM2 NOOP diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2NoopResponse { + /// Hex-encoded three-byte NOOP payload returned by the ASIC. + pub payload_hex: String, +} + +/// Request body for a live BZM2 loopback diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2LoopbackRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Hex-encoded payload to round-trip through the ASIC loopback opcode. + pub payload_hex: String, +} + +/// Response body for a live BZM2 loopback diagnostic query. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2LoopbackResponse { + /// Hex-encoded payload returned by the ASIC. + pub payload_hex: String, +} + +/// Request body for a live BZM2 register read. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterReadRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Engine or local-register address. + pub engine_address: u16, + /// Register offset within the selected engine or local block. + pub offset: u8, + /// Number of bytes to read. + pub count: u8, +} + +/// Response body for a live BZM2 register read. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterReadResponse { + /// Hex-encoded register payload. + pub value_hex: String, +} + +/// Request body for a live BZM2 register write. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterWriteRequest { + /// Index of the BZM2 UART thread/bus to query. + pub thread_index: usize, + /// ASIC id on that UART bus. + pub asic: u8, + /// Engine or local-register address. + pub engine_address: u16, + /// Register offset within the selected engine or local block. + pub offset: u8, + /// Hex-encoded bytes to write. + pub value_hex: String, +} + +/// Response body for a live BZM2 register write. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct Bzm2RegisterWriteResponse { + /// Number of bytes written to the requested register. + pub bytes_written: usize, +} + /// Job source status. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct SourceState { diff --git a/mujina-miner/src/asic/bzm2/clock.rs b/mujina-miner/src/asic/bzm2/clock.rs new file mode 100644 index 00000000..58c12039 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/clock.rs @@ -0,0 +1,606 @@ +use std::time::Duration; + +use tokio::time::{Instant, sleep}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::uart::{Bzm2UartController, Bzm2UartError}; + +const REF_CLK_MHZ: f32 = 50.0; +const REF_DIVIDER: u8 = 1; +const POST2_DIVIDER: u8 = 0; + +const LOCAL_REG_PLL_POSTDIV: u8 = 0x10; +const LOCAL_REG_PLL_FBDIV: u8 = 0x11; +const LOCAL_REG_PLL_ENABLE: u8 = 0x12; +const LOCAL_REG_PLL_MISC: u8 = 0x13; +const LOCAL_REG_PLL1_POSTDIV: u8 = 0x1a; +const LOCAL_REG_PLL1_FBDIV: u8 = 0x1b; +const LOCAL_REG_PLL1_ENABLE: u8 = 0x1c; +const LOCAL_REG_PLL1_MISC: u8 = 0x1d; + +const LOCAL_REG_CKDCCR_2_0: u8 = 0x56; +const LOCAL_REG_CKDCCR_3_0: u8 = 0x57; +const LOCAL_REG_CKDCCR_4_0: u8 = 0x58; +const LOCAL_REG_CKDCCR_5_0: u8 = 0x59; +const LOCAL_REG_CKDLLR_0_0: u8 = 0x5a; +const LOCAL_REG_CKDLLR_1_0: u8 = 0x5b; +const LOCAL_REG_CKDCCR_2_1: u8 = 0x5e; +const LOCAL_REG_CKDCCR_3_1: u8 = 0x5f; +const LOCAL_REG_CKDCCR_4_1: u8 = 0x60; +const LOCAL_REG_CKDCCR_5_1: u8 = 0x61; +const LOCAL_REG_CKDLLR_0_1: u8 = 0x62; +const LOCAL_REG_CKDLLR_1_1: u8 = 0x63; + +#[derive(Debug, thiserror::Error)] +pub enum Bzm2ClockError { + #[error(transparent)] + Uart(#[from] Bzm2UartError), + + #[error("invalid desired PLL frequency {0} MHz")] + InvalidFrequency(f32), + + #[error("invalid PLL post divider {0}")] + InvalidPostDivider(u8), + + #[error("unsupported DLL duty cycle {0}; supported values are 25, 50, 55, 60, 75")] + InvalidDllDutyCycle(u8), + + #[error( + "PLL {pll:?} on ASIC {asic} did not lock before timeout; last enable value {last_enable:#x}" + )] + PllLockTimeout { + asic: u8, + pll: Bzm2Pll, + last_enable: u32, + }, + + #[error( + "DLL {dll:?} on ASIC {asic} did not lock before timeout; last control value {last_control:#x}" + )] + DllLockTimeout { + asic: u8, + dll: Bzm2Dll, + last_control: u8, + }, + + #[error("DLL {dll:?} on ASIC {asic} reported invalid fincon {fincon:#x}")] + InvalidDllFincon { asic: u8, dll: Bzm2Dll, fincon: u8 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2Pll { + Pll0, + Pll1, +} + +impl Bzm2Pll { + pub(crate) fn register_block(self) -> (u8, u8, u8, u8) { + match self { + Self::Pll0 => ( + LOCAL_REG_PLL_POSTDIV, + LOCAL_REG_PLL_FBDIV, + LOCAL_REG_PLL_ENABLE, + LOCAL_REG_PLL_MISC, + ), + Self::Pll1 => ( + LOCAL_REG_PLL1_POSTDIV, + LOCAL_REG_PLL1_FBDIV, + LOCAL_REG_PLL1_ENABLE, + LOCAL_REG_PLL1_MISC, + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2Dll { + Dll0, + Dll1, +} + +impl Bzm2Dll { + pub(crate) fn registers(self) -> (u8, u8, u8, u8, u8) { + match self { + Self::Dll0 => ( + LOCAL_REG_CKDCCR_2_0, + LOCAL_REG_CKDCCR_3_0, + LOCAL_REG_CKDCCR_4_0, + LOCAL_REG_CKDCCR_5_0, + LOCAL_REG_CKDLLR_0_0, + ), + Self::Dll1 => ( + LOCAL_REG_CKDCCR_2_1, + LOCAL_REG_CKDCCR_3_1, + LOCAL_REG_CKDCCR_4_1, + LOCAL_REG_CKDCCR_5_1, + LOCAL_REG_CKDLLR_0_1, + ), + } + } + + pub(crate) fn fincon_register(self) -> u8 { + match self { + Self::Dll0 => LOCAL_REG_CKDLLR_1_0, + Self::Dll1 => LOCAL_REG_CKDLLR_1_1, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bzm2PllConfig { + pub frequency_mhz: f32, + pub post1_divider: u8, + pub ref_divider: u8, + pub post2_divider: u8, + pub feedback_divider: u16, + pub packed_post_divider: u32, +} + +impl Bzm2PllConfig { + pub fn from_target_frequency( + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + if !frequency_mhz.is_finite() || frequency_mhz <= 0.0 { + return Err(Bzm2ClockError::InvalidFrequency(frequency_mhz)); + } + if post1_divider > 7 { + return Err(Bzm2ClockError::InvalidPostDivider(post1_divider)); + } + + let feedback = REF_DIVIDER as f32 + * (post1_divider as f32 + 1.0) + * (POST2_DIVIDER as f32 + 1.0) + * frequency_mhz + / REF_CLK_MHZ; + let feedback_divider = round_legacy(feedback); + let packed_post_divider = (1u32 << 12) + | ((POST2_DIVIDER as u32) << 9) + | ((post1_divider as u32) << 6) + | REF_DIVIDER as u32; + + Ok(Self { + frequency_mhz, + post1_divider, + ref_divider: REF_DIVIDER, + post2_divider: POST2_DIVIDER, + feedback_divider, + packed_post_divider, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DllConfig { + pub duty_cycle: u8, + pub nde_dll: u8, + pub nde_clk: u8, + pub npi_clk: u8, + pub pibypb: u8, + pub dllfreeze: u8, +} + +impl Bzm2DllConfig { + pub fn from_duty_cycle(duty_cycle: u8) -> Result { + let mut config = Self { + duty_cycle, + nde_dll: 0x1f, + nde_clk: 0x0f, + npi_clk: 0x0, + pibypb: 1, + dllfreeze: 0, + }; + + match duty_cycle { + 50 => {} + 75 => config.nde_clk = 0x17, + 60 => { + config.nde_dll = 0x1d; + config.nde_clk = 0x11; + } + 55 => { + config.nde_dll = 0x1d; + config.nde_clk = 0x0f; + config.npi_clk = 0x4; + } + 25 => config.nde_clk = 0x07, + _ => return Err(Bzm2ClockError::InvalidDllDutyCycle(duty_cycle)), + } + + Ok(config) + } + + fn control2(self) -> u8 { + ((self.npi_clk & 0x7) << 3) | ((self.pibypb & 0x1) << 2) | ((self.dllfreeze & 0x1) << 1) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2PllStatus { + pub pll: Bzm2Pll, + pub enable_register: u32, + pub misc_register: u32, + pub enabled: bool, + pub locked: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DllStatus { + pub dll: Bzm2Dll, + pub control2: u8, + pub control5: u8, + pub coarsecon: u8, + pub fincon: u8, + pub freeze_valid: bool, + pub locked: bool, + pub fincon_valid: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2ClockDebugReport { + pub asic: u8, + pub pll0: Bzm2PllStatus, + pub pll1: Bzm2PllStatus, + pub dll0: Bzm2DllStatus, + pub dll1: Bzm2DllStatus, +} + +pub struct Bzm2ClockController { + uart: Bzm2UartController, +} + +impl Bzm2ClockController { + pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { + Self { + uart: Bzm2UartController::new(reader, writer), + } + } + + pub fn from_uart(uart: Bzm2UartController) -> Self { + Self { uart } + } + + pub fn into_uart(self) -> Bzm2UartController { + self.uart + } + + pub async fn program_pll( + &mut self, + asic: u8, + pll: Bzm2Pll, + config: Bzm2PllConfig, + ) -> Result<(), Bzm2ClockError> { + let (postdiv_reg, fbdiv_reg, _, _) = pll.register_block(); + self.uart + .write_local_reg_u32(asic, fbdiv_reg, config.feedback_divider as u32) + .await?; + self.uart + .write_local_reg_u32(asic, postdiv_reg, config.packed_post_divider) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(()) + } + + pub async fn enable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.write_local_reg_u32(asic, enable_reg, 1).await?; + Ok(()) + } + + pub async fn disable_pll(&mut self, asic: u8, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.write_local_reg_u32(asic, enable_reg, 0).await?; + Ok(()) + } + + pub async fn set_pll_frequency( + &mut self, + asic: u8, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + let config = Bzm2PllConfig::from_target_frequency(frequency_mhz, post1_divider)?; + self.program_pll(asic, pll, config).await?; + Ok(config) + } + + pub async fn broadcast_pll_frequency( + &mut self, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + ) -> Result { + let config = Bzm2PllConfig::from_target_frequency(frequency_mhz, post1_divider)?; + let (postdiv_reg, fbdiv_reg, _, _) = pll.register_block(); + self.uart + .broadcast_local_reg_u32(fbdiv_reg, config.feedback_divider as u32) + .await?; + self.uart + .broadcast_local_reg_u32(postdiv_reg, config.packed_post_divider) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(config) + } + + pub async fn broadcast_enable_pll(&mut self, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.broadcast_local_reg_u32(enable_reg, 1).await?; + Ok(()) + } + + pub async fn broadcast_disable_pll(&mut self, pll: Bzm2Pll) -> Result<(), Bzm2ClockError> { + let (_, _, enable_reg, _) = pll.register_block(); + self.uart.broadcast_local_reg_u32(enable_reg, 0).await?; + Ok(()) + } + + pub async fn wait_for_pll_lock( + &mut self, + asic: u8, + pll: Bzm2Pll, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let (_, _, enable_reg, _) = pll.register_block(); + let start = Instant::now(); + + loop { + let last_enable = self.uart.read_local_reg_u32(asic, enable_reg).await?; + let status = self.read_pll_status(asic, pll).await?; + if status.locked { + return Ok(status); + } + if start.elapsed() >= timeout { + return Err(Bzm2ClockError::PllLockTimeout { + asic, + pll, + last_enable, + }); + } + sleep(poll_interval).await; + } + } + + pub async fn configure_and_lock_pll( + &mut self, + asic: u8, + pll: Bzm2Pll, + frequency_mhz: f32, + post1_divider: u8, + timeout: Duration, + ) -> Result<(Bzm2PllConfig, Bzm2PllStatus), Bzm2ClockError> { + let config = self + .set_pll_frequency(asic, pll, frequency_mhz, post1_divider) + .await?; + self.enable_pll(asic, pll).await?; + let status = self + .wait_for_pll_lock(asic, pll, timeout, Duration::from_millis(100)) + .await?; + Ok((config, status)) + } + + pub async fn read_pll_status( + &mut self, + asic: u8, + pll: Bzm2Pll, + ) -> Result { + let (_, _, enable_reg, misc_reg) = pll.register_block(); + let enable = self.uart.read_local_reg_u32(asic, enable_reg).await?; + let misc = self.uart.read_local_reg_u32(asic, misc_reg).await?; + Ok(Bzm2PllStatus { + pll, + enable_register: enable, + misc_register: misc, + enabled: (enable & 0x1) != 0, + locked: (enable & 0x4) != 0, + }) + } + + pub async fn program_dll( + &mut self, + asic: u8, + dll: Bzm2Dll, + config: Bzm2DllConfig, + ) -> Result<(), Bzm2ClockError> { + let (control2_reg, control3_reg, control4_reg, _, _) = dll.registers(); + self.uart + .write_local_reg_u8(asic, control3_reg, config.nde_dll & 0x1f) + .await?; + self.uart + .write_local_reg_u8(asic, control4_reg, config.nde_clk & 0x1f) + .await?; + self.uart + .write_local_reg_u8(asic, control2_reg, config.control2()) + .await?; + sleep(Duration::from_millis(1)).await; + Ok(()) + } + + pub async fn set_dll_duty_cycle( + &mut self, + asic: u8, + dll: Bzm2Dll, + duty_cycle: u8, + ) -> Result { + let config = Bzm2DllConfig::from_duty_cycle(duty_cycle)?; + self.program_dll(asic, dll, config).await?; + Ok(config) + } + + pub async fn enable_dll(&mut self, asic: u8, dll: Bzm2Dll) -> Result<(), Bzm2ClockError> { + let (_, _, _, control5_reg, _) = dll.registers(); + let value = self.uart.read_local_reg_u8(asic, control5_reg).await?; + self.uart + .write_local_reg_u8(asic, control5_reg, value | 0x1) + .await?; + let value = self.uart.read_local_reg_u8(asic, control5_reg).await?; + self.uart + .write_local_reg_u8(asic, control5_reg, value | (0x1 << 2)) + .await?; + Ok(()) + } + + pub async fn disable_dll(&mut self, asic: u8, dll: Bzm2Dll) -> Result<(), Bzm2ClockError> { + let (_, _, _, control5_reg, _) = dll.registers(); + self.uart.write_local_reg_u8(asic, control5_reg, 0).await?; + Ok(()) + } + + pub async fn wait_for_dll_lock( + &mut self, + asic: u8, + dll: Bzm2Dll, + timeout: Duration, + poll_interval: Duration, + ) -> Result { + let (control2_reg, _, _, control5_reg, _) = dll.registers(); + let control2 = self.uart.read_local_reg_u8(asic, control2_reg).await?; + if (control2 & 0x2) != 0 { + sleep(Duration::from_millis(10)).await; + return self.read_dll_status(asic, dll).await; + } + + let start = Instant::now(); + + loop { + let last_control = self.uart.read_local_reg_u8(asic, control5_reg).await?; + let status = self.read_dll_status(asic, dll).await?; + if status.locked { + return Ok(status); + } + if start.elapsed() >= timeout { + return Err(Bzm2ClockError::DllLockTimeout { + asic, + dll, + last_control, + }); + } + sleep(poll_interval).await; + } + } + + pub async fn ensure_dll_fincon_valid( + &mut self, + asic: u8, + dll: Bzm2Dll, + ) -> Result { + let status = self.read_dll_status(asic, dll).await?; + if !status.fincon_valid { + return Err(Bzm2ClockError::InvalidDllFincon { + asic, + dll, + fincon: status.fincon, + }); + } + Ok(status) + } + + pub async fn configure_and_lock_dll( + &mut self, + asic: u8, + dll: Bzm2Dll, + duty_cycle: u8, + timeout: Duration, + ) -> Result<(Bzm2DllConfig, Bzm2DllStatus), Bzm2ClockError> { + let config = self.set_dll_duty_cycle(asic, dll, duty_cycle).await?; + self.enable_dll(asic, dll).await?; + self.wait_for_dll_lock(asic, dll, timeout, Duration::from_millis(10)) + .await?; + let status = self.ensure_dll_fincon_valid(asic, dll).await?; + Ok((config, status)) + } + + pub async fn read_dll_status( + &mut self, + asic: u8, + dll: Bzm2Dll, + ) -> Result { + let (control2_reg, _, _, control5_reg, coarse_reg) = dll.registers(); + let control2 = self.uart.read_local_reg_u8(asic, control2_reg).await?; + let control5 = self.uart.read_local_reg_u8(asic, control5_reg).await?; + let coarse_raw = self.uart.read_local_reg_u8(asic, coarse_reg).await?; + let fincon = self + .uart + .read_local_reg_u8(asic, dll.fincon_register()) + .await?; + + Ok(Bzm2DllStatus { + dll, + control2, + control5, + coarsecon: (coarse_raw >> 5) & 0x7, + fincon, + freeze_valid: (control2 & 0x2) != 0, + locked: (control5 & 0x2) != 0, + fincon_valid: fincon_is_valid(fincon), + }) + } + + pub async fn debug_report(&mut self, asic: u8) -> Result { + Ok(Bzm2ClockDebugReport { + asic, + pll0: self.read_pll_status(asic, Bzm2Pll::Pll0).await?, + pll1: self.read_pll_status(asic, Bzm2Pll::Pll1).await?, + dll0: self.read_dll_status(asic, Bzm2Dll::Dll0).await?, + dll1: self.read_dll_status(asic, Bzm2Dll::Dll1).await?, + }) + } +} + +pub(crate) fn fincon_is_valid(fincon: u8) -> bool { + !matches!(fincon & 0xf0, 0xf0 | 0x00) && !matches!(fincon & 0xe0, 0xe0 | 0x00) +} + +fn round_legacy(value: f32) -> u16 { + let truncated = value as u16; + if value - truncated as f32 > 0.5 { + truncated.saturating_add(1) + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pll_divider_rounding_matches_legacy_formula() { + let config = Bzm2PllConfig::from_target_frequency(625.0, 0).unwrap(); + assert_eq!(config.ref_divider, 1); + assert_eq!(config.post1_divider, 0); + assert_eq!(config.post2_divider, 0); + assert_eq!(config.feedback_divider, 12); + assert_eq!(config.packed_post_divider, 0x1001); + } + + #[test] + fn pll_divider_rounding_uses_half_down_legacy_behavior() { + assert_eq!(round_legacy(12.49), 12); + assert_eq!(round_legacy(12.50), 12); + assert_eq!(round_legacy(12.51), 13); + } + + #[test] + fn dll_duty_cycle_matches_legacy_presets() { + let duty_55 = Bzm2DllConfig::from_duty_cycle(55).unwrap(); + assert_eq!(duty_55.nde_dll, 0x1d); + assert_eq!(duty_55.nde_clk, 0x0f); + assert_eq!(duty_55.npi_clk, 0x4); + + let duty_75 = Bzm2DllConfig::from_duty_cycle(75).unwrap(); + assert_eq!(duty_75.nde_dll, 0x1f); + assert_eq!(duty_75.nde_clk, 0x17); + assert_eq!(duty_75.npi_clk, 0x0); + } + + #[test] + fn fincon_validation_matches_legacy_rules() { + assert!(fincon_is_valid(0x9c)); + assert!(!fincon_is_valid(0xf4)); + assert!(!fincon_is_valid(0x0f)); + assert!(!fincon_is_valid(0xe1)); + } +} diff --git a/mujina-miner/src/asic/bzm2/control.rs b/mujina-miner/src/asic/bzm2/control.rs new file mode 100644 index 00000000..2b96e693 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/control.rs @@ -0,0 +1,425 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::fs; +use tokio::time::sleep; + +use crate::{ + asic::hash_thread::{AsicEnable, VoltageRegulator}, + hw_trait::{ + gpio::{GpioPin, PinValue}, + i2c::I2c, + }, + peripheral::tps546::Tps546, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PowerRailTelemetry { + pub vin_volts: f32, + pub vout_volts: f32, + pub current_amps: f32, + pub temperature_c: f32, + pub power_watts: f32, +} + +#[async_trait] +pub trait Bzm2PowerRail: Send + Sync { + async fn initialize(&mut self) -> Result<()>; + async fn set_voltage(&mut self, volts: f32) -> Result<()>; + async fn telemetry(&mut self) -> Result; +} + +pub struct Tps546PowerRail { + inner: Tps546, +} + +impl Tps546PowerRail { + pub fn new(inner: Tps546) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> Tps546 { + self.inner + } +} + +#[async_trait] +impl Bzm2PowerRail for Tps546PowerRail { + async fn initialize(&mut self) -> Result<()> { + self.inner.init().await + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.inner.set_vout(volts).await + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: self.inner.get_vin().await? as f32 / 1000.0, + vout_volts: self.inner.get_vout().await? as f32 / 1000.0, + current_amps: self.inner.get_iout().await? as f32 / 1000.0, + temperature_c: self.inner.get_temperature().await? as f32, + power_watts: self.inner.get_power().await? as f32 / 1000.0, + }) + } +} + +#[async_trait] +impl VoltageRegulator for Tps546PowerRail { + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + Bzm2PowerRail::set_voltage(self, volts).await + } +} + +pub struct GpioResetLine { + pin: PIN, + active_low: bool, +} + +impl GpioResetLine { + pub fn new(pin: PIN, active_low: bool) -> Self { + Self { pin, active_low } + } + + pub fn into_inner(self) -> PIN { + self.pin + } + + async fn drive(&mut self, asserted: bool) -> Result<()> + where + PIN: GpioPin, + { + let value = if asserted == self.active_low { + PinValue::Low + } else { + PinValue::High + }; + self.pin.write(value).await?; + Ok(()) + } + + pub async fn pulse(&mut self, assert_for: Duration, settle_for: Duration) -> Result<()> + where + PIN: GpioPin, + { + self.drive(true).await?; + sleep(assert_for).await; + self.drive(false).await?; + sleep(settle_for).await; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct FileGpioPin { + path: String, + high_value: String, + low_value: String, +} + +impl FileGpioPin { + pub fn new( + path: impl Into, + high_value: impl Into, + low_value: impl Into, + ) -> Self { + Self { + path: path.into(), + high_value: high_value.into(), + low_value: low_value.into(), + } + } +} + +#[async_trait] +impl GpioPin for FileGpioPin { + async fn set_mode( + &mut self, + _mode: crate::hw_trait::gpio::PinMode, + ) -> crate::hw_trait::Result<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> crate::hw_trait::Result<()> { + let raw = match value { + PinValue::Low => &self.low_value, + PinValue::High => &self.high_value, + }; + fs::write(&self.path, raw).await?; + Ok(()) + } + + async fn read(&mut self) -> crate::hw_trait::Result { + let raw = fs::read_to_string(&self.path).await?; + if raw.trim() == self.high_value.trim() { + Ok(PinValue::High) + } else { + Ok(PinValue::Low) + } + } +} + +#[async_trait] +impl AsicEnable for GpioResetLine { + async fn enable(&mut self) -> Result<()> { + self.drive(false).await + } + + async fn disable(&mut self) -> Result<()> { + self.drive(true).await + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VoltageStackStep { + pub rail_index: usize, + pub voltage: f32, + pub settle_for: Duration, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Bzm2BringupPlan { + pub assert_reset_before_power: bool, + pub pre_power_delay: Duration, + pub post_power_delay: Duration, + pub release_reset_delay: Duration, + pub steps: Vec, +} + +#[derive(Debug, Clone)] +pub struct FilePowerRail { + set_path: String, + write_scale: f32, + enable_path: Option, + enable_value: Option, +} + +impl FilePowerRail { + pub fn new(path: impl Into, write_scale: f32) -> Self { + Self { + set_path: path.into(), + write_scale, + enable_path: None, + enable_value: None, + } + } + + pub fn with_enable( + mut self, + enable_path: impl Into, + enable_value: impl Into, + ) -> Self { + self.enable_path = Some(enable_path.into()); + self.enable_value = Some(enable_value.into()); + self + } + + fn encode_voltage(&self, volts: f32) -> String { + if (self.write_scale - 1.0).abs() < f32::EPSILON { + format!("{volts:.6}") + } else { + format!("{}", (volts * self.write_scale).round() as i64) + } + } +} + +#[async_trait] +impl Bzm2PowerRail for FilePowerRail { + async fn initialize(&mut self) -> Result<()> { + if let (Some(path), Some(value)) = (&self.enable_path, &self.enable_value) { + fs::write(path, value).await?; + } + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + fs::write(&self.set_path, self.encode_voltage(volts)).await?; + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 0.0, + vout_volts: 0.0, + current_amps: 0.0, + temperature_c: 0.0, + power_watts: 0.0, + }) + } +} + +impl Default for Bzm2BringupPlan { + fn default() -> Self { + Self { + assert_reset_before_power: true, + pre_power_delay: Duration::from_millis(10), + post_power_delay: Duration::from_millis(25), + release_reset_delay: Duration::from_millis(25), + steps: Vec::new(), + } + } +} + +impl Bzm2BringupPlan { + pub async fn apply( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: Bzm2PowerRail, + PIN: GpioPin, + { + if self.assert_reset_before_power { + if let Some(reset_line) = reset_line.as_deref_mut() { + reset_line.disable().await?; + } + sleep(self.pre_power_delay).await; + } + + for rail in rails.iter_mut() { + rail.initialize().await?; + } + + for step in &self.steps { + let rail = rails + .get_mut(step.rail_index) + .ok_or_else(|| anyhow::anyhow!("rail index {} out of range", step.rail_index))?; + rail.set_voltage(step.voltage).await?; + sleep(step.settle_for).await; + } + + sleep(self.post_power_delay).await; + + if let Some(reset_line) = reset_line.as_deref_mut() { + reset_line.enable().await?; + sleep(self.release_reset_delay).await; + } + + Ok(()) + } + + pub async fn shutdown( + &self, + rails: &mut [R], + mut reset_line: Option<&mut GpioResetLine>, + ) -> Result<()> + where + R: Bzm2PowerRail, + PIN: GpioPin, + { + if let Some(reset_line) = reset_line.as_deref_mut() { + reset_line.disable().await?; + } + + for rail in rails.iter_mut().rev() { + rail.set_voltage(0.0).await?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hw_trait::{Result as HwResult, gpio::PinMode}; + + #[derive(Default)] + struct MockPin { + writes: Vec, + } + + #[async_trait] + impl GpioPin for MockPin { + async fn set_mode(&mut self, _mode: PinMode) -> HwResult<()> { + Ok(()) + } + + async fn write(&mut self, value: PinValue) -> HwResult<()> { + self.writes.push(value); + Ok(()) + } + + async fn read(&mut self) -> HwResult { + Ok(self.writes.last().copied().unwrap_or(PinValue::Low)) + } + } + + #[derive(Default)] + struct MockRail { + initialized: bool, + voltages: Vec, + } + + #[async_trait] + impl Bzm2PowerRail for MockRail { + async fn initialize(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + async fn set_voltage(&mut self, volts: f32) -> Result<()> { + self.voltages.push(volts); + Ok(()) + } + + async fn telemetry(&mut self) -> Result { + Ok(PowerRailTelemetry { + vin_volts: 12.0, + vout_volts: self.voltages.last().copied().unwrap_or_default(), + current_amps: 1.0, + temperature_c: 42.0, + power_watts: 12.0, + }) + } + } + + #[tokio::test] + async fn bringup_plan_sequences_rails_then_releases_reset() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = Bzm2BringupPlan { + pre_power_delay: Duration::from_millis(0), + post_power_delay: Duration::from_millis(0), + release_reset_delay: Duration::from_millis(0), + steps: vec![ + VoltageStackStep { + rail_index: 0, + voltage: 0.82, + settle_for: Duration::from_millis(0), + }, + VoltageStackStep { + rail_index: 1, + voltage: 0.79, + settle_for: Duration::from_millis(0), + }, + ], + ..Default::default() + }; + + plan.apply(&mut rails, Some(&mut reset)).await.unwrap(); + + assert!(rails[0].initialized); + assert!(rails[1].initialized); + assert_eq!(rails[0].voltages, vec![0.82]); + assert_eq!(rails[1].voltages, vec![0.79]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low, PinValue::High]); + } + + #[tokio::test] + async fn shutdown_plan_asserts_reset_then_powers_off_rails() { + let mut rails = vec![MockRail::default(), MockRail::default()]; + let mut reset = GpioResetLine::new(MockPin::default(), true); + let plan = Bzm2BringupPlan::default(); + + plan.shutdown(&mut rails, Some(&mut reset)).await.unwrap(); + + assert_eq!(rails[0].voltages, vec![0.0]); + assert_eq!(rails[1].voltages, vec![0.0]); + let pin = reset.into_inner(); + assert_eq!(pin.writes, vec![PinValue::Low]); + } +} diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs new file mode 100644 index 00000000..c222c945 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -0,0 +1,31 @@ +pub mod clock; +pub mod control; +pub mod pnp; +pub mod protocol; +pub mod thread; +pub mod uart; + +pub use clock::{ + Bzm2ClockController, Bzm2ClockDebugReport, Bzm2ClockError, Bzm2Dll, Bzm2DllConfig, + Bzm2DllStatus, Bzm2Pll, Bzm2PllConfig, Bzm2PllStatus, +}; +pub use control::{ + Bzm2BringupPlan, FileGpioPin, FilePowerRail, GpioResetLine, PowerRailTelemetry, + Tps546PowerRail, VoltageStackStep, +}; +pub use pnp::{ + Bzm2AsicMeasurement, Bzm2AsicPlan, Bzm2AsicTopology, Bzm2BoardCalibrationInput, + Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlan, Bzm2CalibrationPlanner, + Bzm2CalibrationSweepRequest, Bzm2DomainMeasurement, Bzm2DomainPlan, Bzm2OperatingClass, + Bzm2PerformanceMode, Bzm2SavedEngineCoordinate, Bzm2SavedEngineTopology, + Bzm2SavedOperatingPoint, Bzm2VoltageDomain, +}; +pub use protocol::Bzm2EngineLayout; +pub use thread::{ + Bzm2AsicRuntimeMetrics, Bzm2PllRuntimeMetrics, Bzm2Thread, Bzm2ThreadConfig, Bzm2ThreadHandle, + Bzm2ThreadRuntimeMetrics, +}; +pub use uart::{ + BROADCAST_GROUP_ASIC, Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, Bzm2EngineCoordinate, + Bzm2UartController, Bzm2UartError, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, +}; diff --git a/mujina-miner/src/asic/bzm2/pnp.rs b/mujina-miner/src/asic/bzm2/pnp.rs new file mode 100644 index 00000000..ffa6fbe7 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/pnp.rs @@ -0,0 +1,998 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const CALI_VOLTAGE_MV: u32 = 50; +const CALI_FREQ_MHZ: f32 = 25.0; +const CALI_PASS_RATE_STEP: f32 = 0.0025; +const TARGET_VOLTAGE_MAX_MV: u32 = 21_000; +const TARGET_VOLTAGE_MIN_MV: u32 = 16_950; +const TARGET_FREQ_MAX_MHZ: f32 = 2_000.0; +const TARGET_FREQ_MIN_MHZ: f32 = 800.0; +const TARGET_FREQ_HIGH_PLUS_MHZ: f32 = 1_312.5; +const TARGET_FREQ_HIGH_MHZ: f32 = 1_200.0; +const TARGET_FREQ_BALANCED_MHZ: f32 = 1_150.0; +const TARGET_FREQ_LOW_MHZ: f32 = 1_000.0; +const FREQ_RANGE_MHZ: f32 = 100.0; +const MAX_FREQ_RANGE_MHZ: f32 = 150.0; +const ACCEPT_RATIO_BAND_MAX_THROUGHPUT: f32 = 0.02; +const ACCEPT_RATIO_BAND_STANDARD: f32 = 0.02; +const ACCEPT_RATIO_BAND_EFFICIENCY: f32 = 0.02; +const MIN_ACCEPT_RATIO: f32 = 0.90; +const DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_STANDARD: f32 = 0.975; +const DESIRED_ACCEPT_RATIO_EFFICIENCY: f32 = 0.975; +const STARTUP_VOLTAGE_BIAS_MV: i32 = 50; +const SITE_TEMP_COLD_SOAK_C: f32 = -2.5; +const SITE_TEMP_COOL_C: f32 = 7.5; +const SITE_TEMP_NOMINAL_C: f32 = 17.5; +const SITE_TEMP_WARM_C: f32 = 27.5; +const DEFAULT_THERMAL_THRESHOLD_C: f32 = 100.0; +const DEFAULT_AVG_THERMAL_THRESHOLD_C: f32 = 85.0; +const DEFAULT_CURRENT_THRESHOLD_A: f32 = 260.0; +const DEFAULT_POWER_THRESHOLD_W: f32 = 4_900.0; +const DEFAULT_FREQ_INCREASE_RATIO_HIGH: f32 = 0.28; +const DEFAULT_FREQ_INCREASE_RATIO_LOW: f32 = 0.24; +const DEFAULT_RECALIBRATE_THROUGHPUT_RATIO: f32 = 0.80; +const NOMINAL_ACTIVE_ENGINE_COUNT: u16 = 236; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Bzm2PerformanceMode { + MaxThroughput, + Standard, + Efficiency, +} + +impl Bzm2PerformanceMode { + fn pass_rate_range(self) -> f32 { + match self { + Self::MaxThroughput => ACCEPT_RATIO_BAND_MAX_THROUGHPUT, + Self::Standard => ACCEPT_RATIO_BAND_STANDARD, + Self::Efficiency => ACCEPT_RATIO_BAND_EFFICIENCY, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bzm2OperatingClass { + Generic, + EarlyValidation, + ProductionValidation, + StackTunedA, + StackTunedB, + ExtendedHeadroom, + ExtendedHeadroomB, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Bzm2CalibrationMode { + pub sweep_strategy: bool, + pub sweep_voltage: bool, + pub sweep_frequency: bool, + pub sweep_pass_rate: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationConstraints { + pub max_power_w: f32, + pub max_current_a: f32, + pub max_thermal_c: f32, + pub max_avg_thermal_c: f32, + pub freq_range_mhz: f32, + pub max_freq_range_mhz: f32, + pub recalibrate_throughput_ratio: f32, +} + +impl Default for Bzm2CalibrationConstraints { + fn default() -> Self { + Self { + max_power_w: DEFAULT_POWER_THRESHOLD_W, + max_current_a: DEFAULT_CURRENT_THRESHOLD_A, + max_thermal_c: DEFAULT_THERMAL_THRESHOLD_C, + max_avg_thermal_c: DEFAULT_AVG_THERMAL_THRESHOLD_C, + freq_range_mhz: FREQ_RANGE_MHZ, + max_freq_range_mhz: MAX_FREQ_RANGE_MHZ, + recalibrate_throughput_ratio: DEFAULT_RECALIBRATE_THROUGHPUT_RATIO, + } + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationSweepRequest { + pub operating_class: Bzm2OperatingClass, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub voltage_steps: u8, + pub frequency_steps: u8, + pub pass_rate_steps: u8, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Bzm2SavedOperatingPoint { + pub board_voltage_mv: u32, + pub board_throughput_ths: f32, + #[serde(default)] + pub per_domain_voltage_mv: BTreeMap, + #[serde(default)] + pub per_asic_engine_topology: BTreeMap, + pub per_asic_pll_mhz: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineCoordinate { + pub row: u8, + pub col: u8, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Bzm2SavedEngineTopology { + #[serde(default)] + pub active_engine_count: u16, + #[serde(default)] + pub missing_engines: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicTopology { + pub asic_id: u16, + pub domain_id: u16, + pub pll_count: usize, + pub alive: bool, + pub active_engine_count: u16, + pub missing_engines: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2VoltageDomain { + pub domain_id: u16, + pub asic_ids: Vec, + pub voltage_offset_mv: i32, + pub max_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2DomainMeasurement { + pub domain_id: u16, + pub measured_voltage_mv: Option, + pub measured_power_w: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2AsicMeasurement { + pub asic_id: u16, + pub temperature_c: Option, + pub throughput_ths: Option, + pub average_pass_rate: Option, + pub pll_pass_rates: [Option; 2], +} + +#[derive(Debug, Clone)] +pub struct Bzm2BoardCalibrationInput { + pub operating_class: Bzm2OperatingClass, + pub site_temp_c: f32, + pub target_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub per_stack_clocking: bool, + pub voltage_domains: Vec, + pub asics: Vec, + pub saved_operating_point: Option, + pub domain_measurements: Vec, + pub asic_measurements: Vec, + pub constraints: Bzm2CalibrationConstraints, + pub force_retune: bool, +} + +#[derive(Debug, Clone)] +pub struct Bzm2DomainPlan { + pub domain_id: u16, + pub voltage_mv: u32, + pub average_frequency_mhz: f32, + pub guarded: bool, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2AsicPlan { + pub asic_id: u16, + pub domain_id: u16, + pub pll_frequencies_mhz: [f32; 2], + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationPlan { + pub reuse_saved_operating_point: bool, + pub needs_retune: bool, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, + pub initial_voltage_mv: u32, + pub initial_frequency_mhz: f32, + pub freq_increase_threshold_mhz: f32, + pub search_space: Vec, + pub domain_plans: Vec, + pub asic_plans: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Bzm2ParameterSet { + pub mode: Bzm2PerformanceMode, + pub desired_voltage_mv: u32, + pub desired_clock_mhz: f32, + pub desired_accept_ratio: f32, +} + +#[derive(Debug, Default)] +pub struct Bzm2CalibrationPlanner; + +impl Bzm2CalibrationPlanner { + pub fn build_search_space( + &self, + request: &Bzm2CalibrationSweepRequest, + ) -> Vec { + let modes = if request.mode.sweep_strategy { + vec![ + Bzm2PerformanceMode::MaxThroughput, + Bzm2PerformanceMode::Standard, + Bzm2PerformanceMode::Efficiency, + ] + } else { + vec![request.target_mode] + }; + + let mut parameters = Vec::new(); + for mode in modes { + let target = operating_targets(request.operating_class, mode); + let voltage_offsets = build_offsets(request.mode.sweep_voltage, request.voltage_steps); + let frequency_offsets = + build_frequency_offsets(request.mode.sweep_frequency, request.frequency_steps); + let pass_rate_offsets = + build_pass_rate_offsets(request.mode.sweep_pass_rate, request.pass_rate_steps); + + for voltage_offset in &voltage_offsets { + for frequency_offset in &frequency_offsets { + for pass_rate_offset in &pass_rate_offsets { + parameters.push(Bzm2ParameterSet { + mode, + desired_voltage_mv: clamp_voltage(apply_i32( + target.voltage_mv, + *voltage_offset, + )), + desired_clock_mhz: clamp_frequency( + target.frequency_mhz + *frequency_offset, + ), + desired_accept_ratio: clamp_pass_rate( + target.pass_rate + *pass_rate_offset, + ), + }); + } + } + } + } + + parameters.sort_by(|a, b| { + a.mode + .cmp(&b.mode) + .then(a.desired_voltage_mv.cmp(&b.desired_voltage_mv)) + .then_with(|| a.desired_clock_mhz.total_cmp(&b.desired_clock_mhz)) + .then_with(|| a.desired_accept_ratio.total_cmp(&b.desired_accept_ratio)) + }); + parameters.dedup_by(|a, b| { + a.mode == b.mode + && a.desired_voltage_mv == b.desired_voltage_mv + && (a.desired_clock_mhz - b.desired_clock_mhz).abs() < f32::EPSILON + && (a.desired_accept_ratio - b.desired_accept_ratio).abs() < f32::EPSILON + }); + parameters + } + + pub fn plan(&self, input: &Bzm2BoardCalibrationInput) -> Bzm2CalibrationPlan { + let target = operating_targets(input.operating_class, input.target_mode); + let search_space = self.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: input.operating_class, + target_mode: input.target_mode, + mode: input.mode, + voltage_steps: 4, + frequency_steps: 4, + pass_rate_steps: 2, + }); + + let current_throughput = input + .asic_measurements + .iter() + .filter_map(|asic| asic.throughput_ths) + .sum::(); + let has_live_throughput = input + .asic_measurements + .iter() + .any(|asic| asic.throughput_ths.is_some()); + let live_active_engines = total_active_engine_count(&input.asics); + let reuse_saved_operating_point = + input.saved_operating_point.as_ref().is_some_and(|stored| { + let current_normalized_throughput = + normalize_throughput(current_throughput, live_active_engines); + let stored_normalized_throughput = normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ); + !input.force_retune + && stored.per_asic_pll_mhz.len() + == input.asics.iter().filter(|asic| asic.alive).count() + && (!has_live_throughput + || current_normalized_throughput + >= stored_normalized_throughput + * input.constraints.recalibrate_throughput_ratio) + }); + let needs_retune = input.force_retune + || (has_live_throughput + && input.saved_operating_point.as_ref().is_some_and(|stored| { + normalize_throughput(current_throughput, live_active_engines) + < normalize_throughput( + stored.board_throughput_ths, + stored_total_active_engine_count( + stored, + input.asics.iter().filter(|asic| asic.alive).count(), + ), + ) * input.constraints.recalibrate_throughput_ratio + })); + + let (initial_voltage_mv, freq_increase_threshold_mhz) = initial_voltage_and_threshold( + target.voltage_mv, + input.site_temp_c, + input.mode.sweep_frequency, + ); + let initial_frequency_mhz = clamp_frequency( + (target.frequency_mhz - input.constraints.freq_range_mhz).max(TARGET_FREQ_MIN_MHZ), + ); + + let domain_measurements: BTreeMap = input + .domain_measurements + .iter() + .map(|measurement| (measurement.domain_id, measurement)) + .collect(); + let asic_measurements: BTreeMap = input + .asic_measurements + .iter() + .map(|measurement| (measurement.asic_id, measurement)) + .collect(); + + let mut domain_plans = Vec::new(); + let mut asic_plans = Vec::new(); + let mut notes = Vec::new(); + + for domain in &input.voltage_domains { + let domain_target_voltage = + clamp_voltage(apply_i32(initial_voltage_mv, domain.voltage_offset_mv)); + let domain_power = domain_measurements + .get(&domain.domain_id) + .and_then(|measurement| measurement.measured_power_w) + .unwrap_or_default(); + let domain_guarded = domain.max_power_w.is_some_and(|limit| domain_power > limit) + || domain_power > input.constraints.max_power_w; + + let domain_asics: Vec<&Bzm2AsicTopology> = input + .asics + .iter() + .filter(|asic| asic.alive && asic.domain_id == domain.domain_id) + .collect(); + let domain_avg_temp = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.temperature_c), + ); + let domain_avg_pass_rate = average( + domain_asics + .iter() + .filter_map(|asic| asic_measurements.get(&asic.asic_id)) + .filter_map(|measurement| measurement.average_pass_rate), + ); + + let mut domain_frequency = initial_frequency_mhz; + let mut domain_notes = Vec::new(); + if let Some(pass_rate) = domain_avg_pass_rate { + if pass_rate >= target.pass_rate && !domain_guarded { + domain_frequency = clamp_frequency( + target + .frequency_mhz + .min(initial_frequency_mhz + input.constraints.max_freq_range_mhz), + ); + domain_notes.push(format!( + "domain average pass rate {:.2}% supports target frequency", + pass_rate * 100.0 + )); + } else { + domain_notes.push(format!( + "domain average pass rate {:.2}% below target {:.2}%", + pass_rate * 100.0, + target.pass_rate * 100.0 + )); + } + } + if let Some(temp) = domain_avg_temp { + if temp >= input.constraints.max_avg_thermal_c { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push(format!( + "domain average temperature {:.1}C triggered thermal guard", + temp + )); + } + } + if domain_guarded { + domain_frequency = clamp_frequency(domain_frequency - CALI_FREQ_MHZ); + domain_notes.push("domain power guard active".into()); + } + + domain_plans.push(Bzm2DomainPlan { + domain_id: domain.domain_id, + voltage_mv: domain_target_voltage, + average_frequency_mhz: domain_frequency, + guarded: domain_guarded, + notes: domain_notes.clone(), + }); + + for asic in domain_asics { + let measurement = asic_measurements.get(&asic.asic_id).copied(); + let mut pll_frequencies = [domain_frequency; 2]; + let mut asic_notes = Vec::new(); + + if reuse_saved_operating_point { + if let Some(stored) = input + .saved_operating_point + .as_ref() + .and_then(|stored| stored.per_asic_pll_mhz.get(&asic.asic_id)) + { + pll_frequencies = *stored; + asic_notes.push("reusing stored per-ASIC calibration".into()); + } + } else if let Some(measurement) = measurement { + if let Some(temp) = measurement.temperature_c { + if temp >= input.constraints.max_thermal_c { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC temperature {:.1}C exceeded thermal threshold", + temp + )); + } + } + + if input.per_stack_clocking { + for (pll_index, pass_rate) in measurement.pll_pass_rates.iter().enumerate() + { + if let Some(pass_rate) = pass_rate { + let low = target.pass_rate - input.target_mode.pass_rate_range(); + let high = target.pass_rate + input.target_mode.pass_rate_range(); + if *pass_rate < low { + pll_frequencies[pll_index] = + clamp_frequency(pll_frequencies[pll_index] - CALI_FREQ_MHZ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% below window", + pll_index, + pass_rate * 100.0 + )); + } else if *pass_rate > high && !domain_guarded { + pll_frequencies[pll_index] = clamp_frequency( + pll_frequencies[pll_index] + CALI_FREQ_MHZ / 2.0, + ); + asic_notes.push(format!( + "PLL {} pass rate {:.2}% above window", + pll_index, + pass_rate * 100.0 + )); + } + } + } + } else if let Some(pass_rate) = measurement.average_pass_rate { + if pass_rate < target.pass_rate - input.target_mode.pass_rate_range() { + pll_frequencies = + [clamp_frequency(domain_frequency - CALI_FREQ_MHZ); 2]; + asic_notes.push(format!( + "ASIC pass rate {:.2}% below target window", + pass_rate * 100.0 + )); + } + } + } + + if domain_guarded { + asic_notes.push("bounded by domain power guard".into()); + } + if asic.active_engine_count < NOMINAL_ACTIVE_ENGINE_COUNT { + asic_notes.push(format!( + "ASIC has {} active engines and {} missing coordinates", + asic.active_engine_count, + asic.missing_engines.len() + )); + } + + asic_plans.push(Bzm2AsicPlan { + asic_id: asic.asic_id, + domain_id: asic.domain_id, + pll_frequencies_mhz: pll_frequencies, + notes: asic_notes, + }); + } + } + + if reuse_saved_operating_point { + notes.push("saved operating point is consistent with current throughput".into()); + } else if needs_retune { + notes.push( + "saved operating point is missing or underperforming; full retune required".into(), + ); + } else { + notes.push("building fresh domain-aware calibration plan".into()); + } + + if live_active_engines < total_nominal_engine_capacity(&input.asics) { + notes.push(format!( + "tuning normalized throughput against {:.1}% active engine capacity", + (live_active_engines as f32 / total_nominal_engine_capacity(&input.asics) as f32) + * 100.0 + )); + } + + if input.voltage_domains.len() > 1 { + notes.push("domain-first planning enabled for multi-domain hardware".into()); + } + if input.asics.len() >= 100 { + notes.push("planner uses one domain aggregation pass and one ASIC tuning pass".into()); + } + + Bzm2CalibrationPlan { + reuse_saved_operating_point, + needs_retune, + desired_voltage_mv: target.voltage_mv, + desired_clock_mhz: target.frequency_mhz, + desired_accept_ratio: target.pass_rate, + initial_voltage_mv, + initial_frequency_mhz, + freq_increase_threshold_mhz, + search_space, + domain_plans, + asic_plans, + notes, + } + } +} + +fn total_active_engine_count(asics: &[Bzm2AsicTopology]) -> u32 { + asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| u32::from(asic.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn total_nominal_engine_capacity(asics: &[Bzm2AsicTopology]) -> u32 { + (asics.iter().filter(|asic| asic.alive).count() as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)) + .max(1) +} + +fn stored_total_active_engine_count(stored: &Bzm2SavedOperatingPoint, alive_asics: usize) -> u32 { + if stored.per_asic_engine_topology.is_empty() { + return (alive_asics as u32 * u32::from(NOMINAL_ACTIVE_ENGINE_COUNT)).max(1); + } + + stored + .per_asic_engine_topology + .values() + .map(|topology| u32::from(topology.active_engine_count.max(1))) + .sum::() + .max(1) +} + +fn normalize_throughput(throughput_ths: f32, active_engine_count: u32) -> f32 { + throughput_ths / active_engine_count.max(1) as f32 +} + +#[derive(Debug, Clone, Copy)] +struct OperatingTarget { + voltage_mv: u32, + frequency_mhz: f32, + pass_rate: f32, +} + +fn operating_targets( + operating_class: Bzm2OperatingClass, + mode: Bzm2PerformanceMode, +) -> OperatingTarget { + let (high_voltage, balanced_voltage, low_voltage, high_freq) = match operating_class { + Bzm2OperatingClass::Generic => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::EarlyValidation => (17_800, 17_700, 17_350, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ProductionValidation => (17_550, 17_450, 17_100, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedA => (17_300, 17_200, 16_850, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::StackTunedB => (17_600, 17_500, 17_150, TARGET_FREQ_HIGH_MHZ), + Bzm2OperatingClass::ExtendedHeadroom => (17_900, 17_450, 17_100, TARGET_FREQ_HIGH_PLUS_MHZ), + Bzm2OperatingClass::ExtendedHeadroomB => { + (18_050, 17_550, 17_150, TARGET_FREQ_HIGH_PLUS_MHZ) + } + }; + + match mode { + Bzm2PerformanceMode::MaxThroughput => OperatingTarget { + voltage_mv: high_voltage, + frequency_mhz: high_freq, + pass_rate: DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT, + }, + Bzm2PerformanceMode::Standard => OperatingTarget { + voltage_mv: balanced_voltage, + frequency_mhz: TARGET_FREQ_BALANCED_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_STANDARD, + }, + Bzm2PerformanceMode::Efficiency => OperatingTarget { + voltage_mv: low_voltage, + frequency_mhz: TARGET_FREQ_LOW_MHZ, + pass_rate: DESIRED_ACCEPT_RATIO_EFFICIENCY, + }, + } +} + +fn build_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0]; + } + + let steps = steps.min(20) as i32; + (-steps..=steps) + .map(|step| step * CALI_VOLTAGE_MV as i32) + .collect() +} + +fn build_frequency_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(16) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_FREQ_MHZ) + .collect() +} + +fn build_pass_rate_offsets(enabled: bool, steps: u8) -> Vec { + if !enabled { + return vec![0.0]; + } + + let steps = steps.min(4) as i32; + (-steps..=steps) + .map(|step| step as f32 * CALI_PASS_RATE_STEP) + .collect() +} + +fn initial_voltage_and_threshold( + desired_voltage_mv: u32, + site_temp_c: f32, + frequency_mode: bool, +) -> (u32, f32) { + let (offset, ratio) = if site_temp_c < SITE_TEMP_COLD_SOAK_C { + (STARTUP_VOLTAGE_BIAS_MV * 2, DEFAULT_FREQ_INCREASE_RATIO_LOW) + } else if site_temp_c < SITE_TEMP_COOL_C { + (STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_NOMINAL_C { + (0, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else if site_temp_c < SITE_TEMP_WARM_C { + (-STARTUP_VOLTAGE_BIAS_MV, DEFAULT_FREQ_INCREASE_RATIO_HIGH) + } else { + ( + -STARTUP_VOLTAGE_BIAS_MV * 2, + DEFAULT_FREQ_INCREASE_RATIO_LOW, + ) + }; + + let threshold = if frequency_mode { + CALI_FREQ_MHZ * DEFAULT_FREQ_INCREASE_RATIO_LOW + } else { + CALI_FREQ_MHZ * ratio + }; + + ( + clamp_voltage(apply_i32(desired_voltage_mv, offset)), + threshold, + ) +} + +fn clamp_voltage(voltage_mv: u32) -> u32 { + voltage_mv.clamp(TARGET_VOLTAGE_MIN_MV, TARGET_VOLTAGE_MAX_MV) +} + +fn clamp_frequency(frequency_mhz: f32) -> f32 { + frequency_mhz.clamp(TARGET_FREQ_MIN_MHZ, TARGET_FREQ_MAX_MHZ) +} + +fn clamp_pass_rate(pass_rate: f32) -> f32 { + pass_rate.clamp(MIN_ACCEPT_RATIO, DESIRED_ACCEPT_RATIO_MAX_THROUGHPUT) +} + +fn apply_i32(value: u32, offset: i32) -> u32 { + if offset >= 0 { + value.saturating_add(offset as u32) + } else { + value.saturating_sub(offset.unsigned_abs()) + } +} + +fn average(values: impl Iterator) -> Option { + let mut total = 0.0; + let mut count = 0usize; + for value in values { + total += value; + count += 1; + } + (count > 0).then_some(total / count as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_space_expands_requested_axes() { + let planner = Bzm2CalibrationPlanner; + let parameters = planner.build_search_space(&Bzm2CalibrationSweepRequest { + operating_class: Bzm2OperatingClass::Generic, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode { + sweep_strategy: true, + sweep_voltage: true, + sweep_frequency: true, + sweep_pass_rate: true, + }, + voltage_steps: 1, + frequency_steps: 1, + pass_rate_steps: 1, + }); + + assert!(parameters.len() > 20); + assert!( + parameters + .iter() + .any(|p| p.mode == Bzm2PerformanceMode::MaxThroughput) + ); + assert!(parameters.iter().any(|p| p.desired_voltage_mv < 17_500)); + assert!( + parameters + .iter() + .any(|p| p.desired_clock_mhz > TARGET_FREQ_BALANCED_MHZ) + ); + } + + #[test] + fn single_asic_plan_prefers_saved_operating_point_when_consistent() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![Bzm2DomainMeasurement { + domain_id: 0, + measured_voltage_mv: Some(17_480), + measured_power_w: Some(320.0), + }], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(72.0), + throughput_ths: Some(40.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert_eq!(plan.asic_plans[0].pll_frequencies_mhz, [1_075.0, 1_075.0]); + } + + #[test] + fn planner_requests_retune_when_throughput_drops() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_150.0, 1_150.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 20.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 50.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::new(), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(74.0), + throughput_ths: Some(20.0), + average_pass_rate: Some(0.94), + pll_pass_rates: [Some(0.94), Some(0.94)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(!plan.reuse_saved_operating_point); + assert!(plan.needs_retune); + } + + #[test] + fn planner_normalizes_saved_throughput_by_active_engine_capacity() { + let planner = Bzm2CalibrationPlanner; + let mut stored = BTreeMap::new(); + stored.insert(0, [1_075.0, 1_075.0]); + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::Generic, + site_temp_c: 15.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + voltage_domains: vec![Bzm2VoltageDomain { + domain_id: 0, + asic_ids: vec![0], + voltage_offset_mv: 0, + max_power_w: None, + }], + asics: vec![Bzm2AsicTopology { + asic_id: 0, + domain_id: 0, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT / 2, + missing_engines: vec![Bzm2SavedEngineCoordinate { row: 0, col: 1 }], + }], + saved_operating_point: Some(Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 42.0, + per_domain_voltage_mv: BTreeMap::new(), + per_asic_engine_topology: BTreeMap::from([( + 0, + Bzm2SavedEngineTopology { + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }, + )]), + per_asic_pll_mhz: stored, + }), + domain_measurements: vec![], + asic_measurements: vec![Bzm2AsicMeasurement { + asic_id: 0, + temperature_c: Some(70.0), + throughput_ths: Some(21.0), + average_pass_rate: Some(0.98), + pll_pass_rates: [Some(0.98), Some(0.98)], + }], + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert!(plan.reuse_saved_operating_point); + assert!(!plan.needs_retune); + assert!( + plan.notes + .iter() + .any(|note| note.contains("active engine capacity")) + ); + } + + #[test] + fn multi_domain_plan_scales_to_large_topology() { + let planner = Bzm2CalibrationPlanner; + let domains: Vec = (0..25) + .map(|domain_id| Bzm2VoltageDomain { + domain_id, + asic_ids: (0..4).map(|offset| domain_id * 4 + offset).collect(), + voltage_offset_mv: if domain_id % 2 == 0 { 0 } else { 25 }, + max_power_w: Some(450.0), + }) + .collect(); + let asics: Vec = (0..100) + .map(|asic_id| Bzm2AsicTopology { + asic_id, + domain_id: asic_id / 4, + pll_count: 2, + alive: true, + active_engine_count: NOMINAL_ACTIVE_ENGINE_COUNT, + missing_engines: Vec::new(), + }) + .collect(); + let domain_measurements: Vec = (0..25) + .map(|domain_id| Bzm2DomainMeasurement { + domain_id, + measured_voltage_mv: Some(17_450), + measured_power_w: Some(if domain_id == 3 { 500.0 } else { 300.0 }), + }) + .collect(); + let asic_measurements: Vec = (0..100) + .map(|asic_id| Bzm2AsicMeasurement { + asic_id, + temperature_c: Some(if asic_id == 13 { 101.0 } else { 74.0 }), + throughput_ths: Some(0.4), + average_pass_rate: Some(if asic_id % 9 == 0 { 0.93 } else { 0.98 }), + pll_pass_rates: [Some(0.97), Some(0.98)], + }) + .collect(); + + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: Bzm2OperatingClass::ExtendedHeadroom, + site_temp_c: 10.0, + target_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: true, + voltage_domains: domains, + asics, + saved_operating_point: None, + domain_measurements, + asic_measurements, + constraints: Bzm2CalibrationConstraints::default(), + force_retune: false, + }); + + assert_eq!(plan.domain_plans.len(), 25); + assert_eq!(plan.asic_plans.len(), 100); + assert!( + plan.domain_plans + .iter() + .find(|domain| domain.domain_id == 3) + .unwrap() + .guarded + ); + assert!( + plan.asic_plans + .iter() + .find(|asic| asic.asic_id == 13) + .unwrap() + .pll_frequencies_mhz[0] + < plan.initial_frequency_mhz + 1.0 + ); + assert!(plan.notes.iter().any(|note| note.contains("domain-first"))); + } +} diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs new file mode 100644 index 00000000..799f1dde --- /dev/null +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -0,0 +1,713 @@ +use std::collections::{HashMap, HashSet}; + +pub const OPCODE_UART_WRITEJOB: u8 = 0x0; +pub const OPCODE_UART_READRESULT: u8 = 0x1; +pub const OPCODE_UART_WRITEREG: u8 = 0x2; +pub const OPCODE_UART_READREG: u8 = 0x3; +pub const OPCODE_UART_MULTICAST_WRITE: u8 = 0x4; +pub const OPCODE_UART_DTS_VS: u8 = 0x0d; +pub const OPCODE_UART_LOOPBACK: u8 = 0x0e; +pub const OPCODE_UART_NOOP: u8 = 0x0f; + +pub const BROADCAST_ASIC: u8 = 0xff; +pub const TARGET_BYTE: u8 = 0x08; + +pub const ENGINE_REG_TARGET: u8 = 0x44; +pub const ENGINE_REG_TIMESTAMP_COUNT: u8 = 0x48; +pub const ENGINE_REG_ZEROS_TO_FIND: u8 = 0x49; +pub const ENGINE_REG_END_NONCE: u8 = 0x40; + +pub const DEFAULT_TIMESTAMP_COUNT: u8 = 60; +pub const DEFAULT_NONCE_GAP: u32 = 0x28; +pub const LOGICAL_ENGINE_ROWS: u8 = 20; +pub const LOGICAL_ENGINE_COLS: u8 = 12; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DtsVsGeneration { + Gen1, + Gen2, +} + +impl DtsVsGeneration { + pub fn from_env_value(raw: &str) -> Option { + match raw.trim() { + "1" | "gen1" | "GEN1" => Some(Self::Gen1), + "2" | "gen2" | "GEN2" => Some(Self::Gen2), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmResultFrame { + pub asic: u8, + pub engine_address: u16, + pub status: u8, + pub nonce: u32, + pub sequence_id: u8, + pub reported_time: u8, +} + +impl TdmResultFrame { + pub fn row(self) -> u8 { + (self.engine_address & 0x3f) as u8 + } + + pub fn col(self) -> u8 { + (self.engine_address >> 6) as u8 + } + + pub fn logical_engine_id(self) -> Option { + logical_engine_id(self.row(), self.col()) + } + + pub fn nonce_valid(self) -> bool { + (self.status & 0x8) != 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TdmRegisterFrame { + pub asic: u8, + pub data: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmNoopFrame { + pub asic: u8, + pub data: [u8; 3], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmDtsVsGen1Frame { + pub asic: u8, + pub voltage: u16, + pub voltage_enabled: bool, + pub thermal_tune_code: u8, + pub thermal_validity: bool, + pub thermal_enabled: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TdmDtsVsGen2Frame { + pub asic: u8, + pub ch0_voltage: u16, + pub ch1_voltage: u16, + pub ch2_voltage: u16, + pub voltage_shutdown_status: bool, + pub voltage_enabled: bool, + pub thermal_tune_code: u16, + pub thermal_trip_status: bool, + pub thermal_fault: bool, + pub thermal_validity: bool, + pub thermal_enabled: bool, + pub voltage_fault: bool, + pub dll0_lock: bool, + pub dll1_lock: bool, + pub pll_lock: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TdmDtsVsFrame { + Gen1(TdmDtsVsGen1Frame), + Gen2(TdmDtsVsGen2Frame), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TdmFrame { + Result(TdmResultFrame), + Register(TdmRegisterFrame), + DtsVs(TdmDtsVsFrame), + Noop(TdmNoopFrame), +} + +pub struct TdmFrameParser { + dts_vs_generation: DtsVsGeneration, + buffer: Vec, + expected_read_lengths: HashMap, +} + +impl Default for TdmFrameParser { + fn default() -> Self { + Self::new(DtsVsGeneration::Gen2) + } +} + +impl TdmFrameParser { + pub fn new(dts_vs_generation: DtsVsGeneration) -> Self { + Self { + dts_vs_generation, + buffer: Vec::new(), + expected_read_lengths: HashMap::new(), + } + } + + pub fn expect_read_register_bytes(&mut self, asic: u8, count: usize) { + self.expected_read_lengths.insert(asic, count); + } + + pub fn push(&mut self, bytes: &[u8]) -> Vec { + self.buffer.extend_from_slice(bytes); + + let mut frames = Vec::new(); + let mut cursor = 0usize; + + while self.buffer.len().saturating_sub(cursor) >= 2 { + let asic = self.buffer[cursor]; + let opcode = self.buffer[cursor + 1]; + + if asic >= 100 { + cursor += 1; + continue; + } + + match opcode { + OPCODE_UART_READRESULT => { + if self.buffer.len().saturating_sub(cursor) < 10 { + break; + } + + let payload = &self.buffer[cursor + 2..cursor + 10]; + let header = u16::from_be_bytes([payload[0], payload[1]]); + let engine_address = header & 0x0fff; + let status = (header >> 12) as u8; + let nonce = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let sequence_id = payload[6]; + let reported_time = payload[7]; + + frames.push(TdmFrame::Result(TdmResultFrame { + asic, + engine_address, + status, + nonce, + sequence_id, + reported_time, + })); + cursor += 10; + } + OPCODE_UART_READREG => { + let Some(&count) = self.expected_read_lengths.get(&asic) else { + break; + }; + if self.buffer.len().saturating_sub(cursor) < 2 + count { + break; + } + + frames.push(TdmFrame::Register(TdmRegisterFrame { + asic, + data: self.buffer[cursor + 2..cursor + 2 + count].to_vec(), + })); + self.expected_read_lengths.remove(&asic); + cursor += 2 + count; + } + OPCODE_UART_DTS_VS => { + let payload_len = match self.dts_vs_generation { + DtsVsGeneration::Gen1 => 4, + DtsVsGeneration::Gen2 => 8, + }; + if self.buffer.len().saturating_sub(cursor) < 2 + payload_len { + break; + } + + let payload = &self.buffer[cursor + 2..cursor + 2 + payload_len]; + let frame = match self.dts_vs_generation { + DtsVsGeneration::Gen1 => { + TdmDtsVsFrame::Gen1(parse_dts_vs_gen1(asic, payload)) + } + DtsVsGeneration::Gen2 => { + TdmDtsVsFrame::Gen2(parse_dts_vs_gen2(asic, payload)) + } + }; + frames.push(TdmFrame::DtsVs(frame)); + cursor += 2 + payload_len; + } + OPCODE_UART_NOOP => { + if self.buffer.len().saturating_sub(cursor) < 5 { + break; + } + let data = self.buffer[cursor + 2..cursor + 5].try_into().unwrap(); + frames.push(TdmFrame::Noop(TdmNoopFrame { asic, data })); + cursor += 5; + } + _ => { + cursor += 1; + } + } + } + + if cursor > 0 { + self.buffer.drain(..cursor); + } + + frames + } +} + +#[derive(Default)] +pub struct TdmResultParser { + inner: TdmFrameParser, +} + +impl TdmResultParser { + pub fn push(&mut self, bytes: &[u8]) -> Vec { + self.inner + .push(bytes) + .into_iter() + .filter_map(|frame| match frame { + TdmFrame::Result(result) => Some(result), + _ => None, + }) + .collect() + } +} + +pub fn encode_write_register(asic: u8, engine_address: u16, offset: u8, value: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(7 + value.len()); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_WRITEREG as u32) << 20) + | ((engine_address as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&((7 + value.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((value.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(value); + bytes +} + +pub fn encode_multicast_write(asic: u8, group: u16, offset: u8, value: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(7 + value.len()); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_MULTICAST_WRITE as u32) << 20) + | ((group as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&((7 + value.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((value.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(value); + bytes +} + +pub fn encode_read_register(asic: u8, engine_address: u16, offset: u8, count: u8) -> Vec { + let mut bytes = Vec::with_capacity(8); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_READREG as u32) << 20) + | ((engine_address as u32) << 8) + | offset as u32; + + bytes.extend_from_slice(&8u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push(count.saturating_sub(1)); + bytes.push(TARGET_BYTE); + bytes +} + +pub fn encode_write_job( + asic: u8, + engine_address: u16, + midstate: &[u8; 32], + merkle_root_residue: u32, + ntime: u32, + sequence_id: u8, + job_control: u8, +) -> Vec { + let mut bytes = Vec::with_capacity(48); + let header = ((asic as u32) << 24) + | ((OPCODE_UART_WRITEJOB as u32) << 20) + | ((engine_address as u32) << 8) + | 41u32; + + bytes.extend_from_slice(&(48u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.extend_from_slice(midstate); + bytes.extend_from_slice(&merkle_root_residue.to_le_bytes()); + bytes.extend_from_slice(&ntime.to_le_bytes()); + bytes.push(sequence_id); + bytes.push(job_control); + bytes +} + +pub fn encode_read_result_command(asic: u8) -> Vec { + let mut bytes = Vec::with_capacity(4); + let header = ((asic as u16) << 8) | ((OPCODE_UART_READRESULT as u16) << 4); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes +} + +pub fn encode_noop(asic: u8) -> Vec { + let mut bytes = Vec::with_capacity(4); + let header = ((asic as u16) << 8) | ((OPCODE_UART_NOOP as u16) << 4); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes +} + +pub fn encode_loopback(asic: u8, data: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(5 + data.len()); + let header = ((asic as u16) << 8) | ((OPCODE_UART_LOOPBACK as u16) << 4); + bytes.extend_from_slice(&((5 + data.len()) as u16).to_le_bytes()); + bytes.extend_from_slice(&header.to_be_bytes()); + bytes.push((data.len() as u8).saturating_sub(1)); + bytes.extend_from_slice(data); + bytes +} + +pub fn logical_engine_address(row: u8, col: u8) -> u16 { + ((col as u16) << 6) | row as u16 +} + +pub fn logical_engine_id(row: u8, col: u8) -> Option { + if row >= LOGICAL_ENGINE_ROWS || col >= LOGICAL_ENGINE_COLS { + return None; + } + if default_excluded_engines().contains(&(row, col)) { + return None; + } + + let excluded = default_excluded_engines(); + let mut id = 0u16; + for c in 0..LOGICAL_ENGINE_COLS { + for r in 0..LOGICAL_ENGINE_ROWS { + if excluded.contains(&(r, c)) { + continue; + } + if r == row && c == col { + return Some(id); + } + id += 1; + } + } + + None +} + +pub fn default_excluded_engines() -> HashSet<(u8, u8)> { + HashSet::from([(0, 4), (0, 5), (19, 5), (19, 11)]) +} + +pub fn physical_engine_coordinates() -> Vec<(u8, u8)> { + let mut coords = Vec::new(); + for col in 0..LOGICAL_ENGINE_COLS { + for row in 0..LOGICAL_ENGINE_ROWS { + coords.push((row, col)); + } + } + coords +} + +pub fn default_engine_coordinates() -> Vec<(u8, u8)> { + let excluded = default_excluded_engines(); + let mut coords = Vec::new(); + for col in 0..LOGICAL_ENGINE_COLS { + for row in 0..LOGICAL_ENGINE_ROWS { + if excluded.contains(&(row, col)) { + continue; + } + coords.push((row, col)); + } + } + coords +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2EngineLayout { + active_coordinates: Vec<(u8, u8)>, + logical_ids_by_address: HashMap, +} + +impl Bzm2EngineLayout { + pub fn from_active_coordinates(coords: I) -> Self + where + I: IntoIterator, + { + let mut active_coordinates = coords + .into_iter() + .filter(|(row, col)| *row < LOGICAL_ENGINE_ROWS && *col < LOGICAL_ENGINE_COLS) + .collect::>(); + active_coordinates.sort_by_key(|(row, col)| (*col, *row)); + active_coordinates.dedup(); + + let logical_ids_by_address = active_coordinates + .iter() + .enumerate() + .map(|(logical_id, (row, col))| (logical_engine_address(*row, *col), logical_id as u16)) + .collect(); + + Self { + active_coordinates, + logical_ids_by_address, + } + } + + pub fn active_coordinates(&self) -> &[(u8, u8)] { + &self.active_coordinates + } + + pub fn active_engine_count(&self) -> usize { + self.active_coordinates.len() + } + + pub fn logical_engine_id(&self, row: u8, col: u8) -> Option { + self.logical_engine_id_from_address(logical_engine_address(row, col)) + } + + pub fn logical_engine_id_from_address(&self, engine_address: u16) -> Option { + self.logical_ids_by_address.get(&engine_address).copied() + } +} + +impl Default for Bzm2EngineLayout { + fn default() -> Self { + Self::from_active_coordinates(default_engine_coordinates()) + } +} + +pub fn leading_zero_threshold(target: bitcoin::pow::Target) -> u8 { + let bytes = target.to_be_bytes(); + let mut zeros = 0u8; + + 'outer: for byte in bytes { + if byte == 0 { + zeros = zeros.saturating_add(8); + continue; + } + + for bit in (0..8).rev() { + if (byte & (1 << bit)) == 0 { + zeros = zeros.saturating_add(1); + } else { + break 'outer; + } + } + break; + } + + zeros.clamp(32, 64) +} + +fn parse_dts_vs_gen1(asic: u8, payload: &[u8]) -> TdmDtsVsGen1Frame { + let raw = u32::from_be_bytes(payload.try_into().unwrap()); + let bytes = raw.to_le_bytes(); + let voltage = (((bytes[1] & 0x07) as u16) << 8) | bytes[0] as u16; + + TdmDtsVsGen1Frame { + asic, + voltage, + voltage_enabled: (bytes[1] & 0x80) != 0, + thermal_tune_code: bytes[2], + thermal_validity: (bytes[3] & 0x40) != 0, + thermal_enabled: (bytes[3] & 0x80) != 0, + } +} + +fn parse_dts_vs_gen2(asic: u8, payload: &[u8]) -> TdmDtsVsGen2Frame { + let raw = u64::from_be_bytes(payload.try_into().unwrap()); + let bytes = raw.to_le_bytes(); + + TdmDtsVsGen2Frame { + asic, + ch0_voltage: (((bytes[2] & 0x3f) as u16) << 8) | bytes[3] as u16, + ch1_voltage: ((bytes[4] as u16) << 6) | ((bytes[5] & 0x3f) as u16), + ch2_voltage: (((bytes[7] & 0x0f) as u16) << 10) + | ((bytes[6] as u16) << 2) + | (((bytes[5] >> 6) & 0x03) as u16), + voltage_shutdown_status: (bytes[2] & 0x40) != 0, + voltage_enabled: (bytes[2] & 0x80) != 0, + thermal_tune_code: (((bytes[0] & 0x0f) as u16) << 8) | bytes[1] as u16, + thermal_trip_status: (bytes[0] & 0x10) != 0, + thermal_fault: (bytes[0] & 0x20) != 0, + thermal_validity: (bytes[0] & 0x40) != 0, + thermal_enabled: (bytes[0] & 0x80) != 0, + voltage_fault: (bytes[7] & 0x10) != 0, + dll0_lock: (bytes[7] & 0x20) != 0, + dll1_lock: (bytes[7] & 0x40) != 0, + pll_lock: (bytes[7] & 0x80) != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_register_encoder_matches_legacy_wire_format() { + let encoded = encode_write_register(0x12, 0x0345, 0x67, &[0x78, 0x56, 0x34, 0x12]); + assert_eq!( + encoded, + vec![ + 0x0b, 0x00, 0x12, 0x23, 0x45, 0x67, 0x03, 0x78, 0x56, 0x34, 0x12 + ] + ); + } + + #[test] + fn read_register_encoder_matches_legacy_wire_format() { + let encoded = encode_read_register(0x12, 0x0345, 0x67, 4); + assert_eq!( + encoded, + vec![0x08, 0x00, 0x12, 0x33, 0x45, 0x67, 0x03, TARGET_BYTE] + ); + } + + #[test] + fn noop_and_loopback_encoders_match_legacy_wire_format() { + assert_eq!(encode_noop(0x12), vec![0x04, 0x00, 0x12, 0xf0]); + assert_eq!( + encode_loopback(0x12, &[0xaa, 0xbb, 0xcc]), + vec![0x08, 0x00, 0x12, 0xe0, 0x02, 0xaa, 0xbb, 0xcc] + ); + } + + #[test] + fn parser_decodes_tdm_result() { + let mut parser = TdmResultParser::default(); + let frame = [ + 0x02, + OPCODE_UART_READRESULT, + 0x41, + 0x23, + 0x78, + 0x56, + 0x34, + 0x12, + 0x05, + 0x09, + ]; + + let parsed = parser.push(&frame); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].asic, 0x02); + assert_eq!(parsed[0].status, 0x4); + assert_eq!(parsed[0].engine_address, 0x0123); + assert_eq!(parsed[0].nonce, 0x1234_5678); + assert_eq!(parsed[0].sequence_id, 0x05); + assert_eq!(parsed[0].reported_time, 0x09); + } + + #[test] + fn parser_decodes_gen2_dts_vs() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + let raw = [ + 0x02, + OPCODE_UART_DTS_VS, + 0xD5, + 0xAB, + 0x34, + 0x12, + 0x45, + 0x96, + 0xA9, + 0xF7, + ]; + let parsed = parser.push(&raw); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + TdmFrame::DtsVs(TdmDtsVsFrame::Gen2(frame)) => { + assert_eq!(frame.asic, 0x02); + assert_eq!(frame.thermal_tune_code, 0x07A9); + assert!(frame.thermal_trip_status); + assert!(frame.thermal_fault); + assert!(frame.thermal_validity); + assert!(frame.thermal_enabled); + assert_eq!(frame.ch0_voltage, 0x1645); + assert!(!frame.voltage_shutdown_status); + assert!(frame.voltage_enabled); + assert_eq!(frame.ch1_voltage, 0x04B4); + assert_eq!(frame.ch2_voltage, 0x16AC); + assert!(frame.voltage_fault); + assert!(!frame.dll0_lock); + assert!(frame.dll1_lock); + assert!(frame.pll_lock); + } + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_decodes_gen1_dts_vs() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen1); + let raw = [0x02, OPCODE_UART_DTS_VS, 0x91, 0xab, 0xcd, 0x45]; + let parsed = parser.push(&raw); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + TdmFrame::DtsVs(TdmDtsVsFrame::Gen1(frame)) => { + assert_eq!(frame.asic, 0x02); + assert_eq!(frame.voltage, 0x545); + assert!(frame.voltage_enabled); + assert_eq!(frame.thermal_tune_code, 0xab); + assert!(!frame.thermal_validity); + assert!(frame.thermal_enabled); + } + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_decodes_readreg_and_noop() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + parser.expect_read_register_bytes(0x03, 4); + let parsed = parser.push(&[ + 0x03, + OPCODE_UART_READREG, + 0x78, + 0x56, + 0x34, + 0x12, + 0x01, + OPCODE_UART_NOOP, + 0xaa, + 0xbb, + 0xcc, + ]); + assert_eq!(parsed.len(), 2); + match &parsed[0] { + TdmFrame::Register(frame) => assert_eq!(frame.data, vec![0x78, 0x56, 0x34, 0x12]), + other => panic!("unexpected frame: {other:?}"), + } + match &parsed[1] { + TdmFrame::Noop(frame) => assert_eq!(frame.data, [0xaa, 0xbb, 0xcc]), + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn parser_resyncs_after_unknown_prefix_and_partial_frames() { + let mut parser = TdmFrameParser::new(DtsVsGeneration::Gen2); + parser.expect_read_register_bytes(0x03, 4); + + let first = parser.push(&[0xfe, 0xaa, 0x03, OPCODE_UART_READREG, 0x78, 0x56]); + assert!(first.is_empty()); + + let second = parser.push(&[0x34, 0x12, 0x01, OPCODE_UART_NOOP, 0xaa, 0xbb, 0xcc]); + assert_eq!(second.len(), 2); + match &second[0] { + TdmFrame::Register(frame) => assert_eq!(frame.data, vec![0x78, 0x56, 0x34, 0x12]), + other => panic!("unexpected frame: {other:?}"), + } + match &second[1] { + TdmFrame::Noop(frame) => assert_eq!(frame.data, [0xaa, 0xbb, 0xcc]), + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn command_encoders_cover_all_uart_opcodes() { + let writereg = encode_write_register(1, 2, 3, &[0x44]); + let writejob = encode_write_job(1, 2, &[0u8; 32], 4, 5, 6, 7); + let readreg = encode_read_register(1, 2, 3, 4); + let multicast = encode_multicast_write(1, 2, 3, &[0x44]); + let readresult = encode_read_result_command(1); + let noop = encode_noop(1); + let loopback = encode_loopback(1, &[0xaa, 0xbb]); + + assert_eq!(writereg[3] >> 4, OPCODE_UART_WRITEREG); + assert_eq!(writejob[3] >> 4, OPCODE_UART_WRITEJOB); + assert_eq!(readreg[3] >> 4, OPCODE_UART_READREG); + assert_eq!(multicast[3] >> 4, OPCODE_UART_MULTICAST_WRITE); + assert_eq!(readresult[3] >> 4, OPCODE_UART_READRESULT); + assert_eq!(noop[3] >> 4, OPCODE_UART_NOOP); + assert_eq!(loopback[3] >> 4, OPCODE_UART_LOOPBACK); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs new file mode 100644 index 00000000..11858a76 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -0,0 +1,2084 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use bitcoin::block::Header as BlockHeader; +use bitcoin::consensus::serialize; +use bitcoin::hashes::{HashEngine, sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; + +use crate::asic::hash_thread::{ + HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, + HashThreadPowerReading, HashThreadStatus, HashThreadTelemetryUpdate, + HashThreadTemperatureReading, Share, +}; +use crate::job_source::{GeneralPurposeBits, MerkleRootKind}; +use crate::tracing::prelude::*; +use crate::transport::serial::{SerialControl, SerialReader, SerialWriter}; +use crate::types::{Difficulty, HashRate, HashrateEstimator, Work}; + +use super::clock::{ + Bzm2ClockDebugReport, Bzm2Dll, Bzm2DllStatus, Bzm2Pll, Bzm2PllStatus, fincon_is_valid, +}; +use super::protocol::{ + self, BROADCAST_ASIC, Bzm2EngineLayout, DEFAULT_NONCE_GAP, DEFAULT_TIMESTAMP_COUNT, + DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, + OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, + TdmFrameParser, encode_loopback, encode_noop, encode_read_register, encode_write_job, + encode_write_register, leading_zero_threshold, logical_engine_address, +}; +use super::uart::{ + Bzm2DiscoveredEngineMap, Bzm2DtsVsConfig, DEFAULT_DTS_VS_QUERY_TIMEOUT, + configure_dts_vs_stream, discover_engine_map_stream, +}; + +#[derive(Debug, Clone)] +pub struct Bzm2ThreadConfig { + pub serial_path: String, + pub baud_rate: u32, + pub timestamp_count: u8, + pub nonce_gap: u32, + pub dispatch_interval: Duration, + pub nominal_hashrate_ths: f64, + pub dts_vs_generation: DtsVsGeneration, +} + +impl Bzm2ThreadConfig { + pub fn new(serial_path: String, baud_rate: u32) -> Self { + Self { + serial_path, + baud_rate, + timestamp_count: DEFAULT_TIMESTAMP_COUNT, + nonce_gap: DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(500), + nominal_hashrate_ths: 40.0, + dts_vs_generation: DtsVsGeneration::Gen2, + } + } +} + +const RUNTIME_MEASUREMENT_WINDOW: Duration = Duration::from_secs(5 * 60); +// Legacy source treats rows 0-9 as the bottom stack (PLL0) and rows 10-19 as +// the top stack (PLL1). +const PLL_STACK_SPLIT_ROW: u8 = 10; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2PllRuntimeMetrics { + pub throughput_hs: Option, + pub scheduler_share_count: u64, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2AsicRuntimeMetrics { + pub asic: u8, + pub throughput_hs: Option, + pub scheduler_share_count: u64, + pub plls: [Bzm2PllRuntimeMetrics; 2], +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bzm2ThreadRuntimeMetrics { + pub throughput_hs: Option, + pub asics: Vec, +} + +struct PllRuntimeMeasurement { + estimator: HashrateEstimator, + scheduler_share_count: u64, +} + +impl PllRuntimeMeasurement { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + scheduler_share_count: 0, + } + } + + fn record_at(&mut self, at: Instant, work: Work) { + self.estimator.record_at(at, work); + self.scheduler_share_count = self.scheduler_share_count.saturating_add(1); + } + + fn snapshot_at(&mut self, now: Instant) -> Bzm2PllRuntimeMetrics { + Bzm2PllRuntimeMetrics { + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + scheduler_share_count: self.scheduler_share_count, + } + } +} + +struct AsicRuntimeMeasurement { + estimator: HashrateEstimator, + scheduler_share_count: u64, + plls: [PllRuntimeMeasurement; 2], +} + +impl AsicRuntimeMeasurement { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + scheduler_share_count: 0, + plls: [PllRuntimeMeasurement::new(), PllRuntimeMeasurement::new()], + } + } + + fn record_at(&mut self, at: Instant, pll_index: usize, work: Work) { + self.estimator.record_at(at, work); + self.scheduler_share_count = self.scheduler_share_count.saturating_add(1); + self.plls[pll_index].record_at(at, work); + } + + fn snapshot_at(&mut self, now: Instant, asic: u8) -> Bzm2AsicRuntimeMetrics { + Bzm2AsicRuntimeMetrics { + asic, + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + scheduler_share_count: self.scheduler_share_count, + plls: [self.plls[0].snapshot_at(now), self.plls[1].snapshot_at(now)], + } + } +} + +struct ThreadRuntimeMeasurementState { + estimator: HashrateEstimator, + asics: BTreeMap, +} + +impl ThreadRuntimeMeasurementState { + fn new() -> Self { + Self { + estimator: HashrateEstimator::new(RUNTIME_MEASUREMENT_WINDOW), + asics: BTreeMap::new(), + } + } + + fn record_at(&mut self, at: Instant, asic: u8, row: u8, work: Work) { + let pll_index = pll_index_for_row(row); + self.estimator.record_at(at, work); + self.asics + .entry(asic) + .or_insert_with(AsicRuntimeMeasurement::new) + .record_at(at, pll_index, work); + } + + fn snapshot_at(&mut self, now: Instant) -> Bzm2ThreadRuntimeMetrics { + Bzm2ThreadRuntimeMetrics { + throughput_hs: self + .estimator + .settled_hashrate() + .map(u64::from) + .or_else(|| { + self.estimator + .has_samples() + .then(|| u64::from(self.estimator.hashrate_at(now))) + }), + asics: self + .asics + .iter_mut() + .map(|(&asic, measurement)| measurement.snapshot_at(now, asic)) + .collect(), + } + } + + fn current_hashrate( + &mut self, + now: Instant, + is_active: bool, + nominal_hashrate_ths: f64, + ) -> HashRate { + if !is_active { + return HashRate::default(); + } + + let measured = self.estimator.settled_hashrate().or_else(|| { + self.estimator + .has_samples() + .then(|| self.estimator.hashrate_at(now)) + }); + match measured { + Some(hashrate) if !hashrate.is_zero() => hashrate, + _ => HashRate::from_terahashes(nominal_hashrate_ths), + } + } +} + +fn pll_index_for_row(row: u8) -> usize { + if row < PLL_STACK_SPLIT_ROW { 0 } else { 1 } +} + +#[derive(Clone)] +pub struct Bzm2ThreadHandle { + command_tx: mpsc::Sender, +} + +impl Bzm2ThreadHandle { + pub fn shutdown(&self) { + let _ = self.command_tx.try_send(ThreadCommand::Shutdown); + } + + pub async fn noop(&self, asic: u8) -> Result<[u8; 3], HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryNoop { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn loopback(&self, asic: u8, payload: Vec) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryLoopback { + asic, + payload, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn read_register( + &self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::ReadRegister { + asic, + engine_address, + offset, + count, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn write_register( + &self, + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + ) -> Result<(), HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::WriteRegister { + asic, + engine_address, + offset, + value, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn query_dts_vs( + &self, + asic: u8, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryDtsVs { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::TelemetryQueryFailed("thread dropped response".into()))? + } + + pub async fn clock_report(&self, asic: u8) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryClockReport { asic, response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn discover_engine_map( + &self, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout: Duration, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::DiscoverEngineMap { + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } + + pub async fn runtime_metrics(&self) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::QueryRuntimeMetrics { response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::DiagnosticsFailed("thread dropped response".into()))? + } +} + +#[derive(Debug)] +enum ThreadCommand { + UpdateTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + ReplaceTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + GoIdle { + response_tx: oneshot::Sender, HashThreadError>>, + }, + QueryNoop { + asic: u8, + response_tx: oneshot::Sender>, + }, + QueryLoopback { + asic: u8, + payload: Vec, + response_tx: oneshot::Sender, HashThreadError>>, + }, + QueryClockReport { + asic: u8, + response_tx: oneshot::Sender>, + }, + ReadRegister { + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + response_tx: oneshot::Sender, HashThreadError>>, + }, + WriteRegister { + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + response_tx: oneshot::Sender>, + }, + QueryDtsVs { + asic: u8, + response_tx: oneshot::Sender>, + }, + DiscoverEngineMap { + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout: Duration, + response_tx: oneshot::Sender>, + }, + QueryRuntimeMetrics { + response_tx: oneshot::Sender>, + }, + Shutdown, +} + +#[derive(Clone)] +struct EngineDispatch { + task: HashTask, + merkle_root: bitcoin::TxMerkleNode, + versions: [bitcoin::block::Version; 4], + base_sequence: u8, +} + +pub struct Bzm2Thread { + name: String, + command_tx: mpsc::Sender, + event_rx: Option>, + capabilities: HashThreadCapabilities, + status: Arc>, +} + +impl Bzm2Thread { + pub fn new( + name: String, + reader: SerialReader, + writer: SerialWriter, + control: SerialControl, + config: Bzm2ThreadConfig, + ) -> Self { + let (command_tx, command_rx) = mpsc::channel(16); + let (event_tx, event_rx) = mpsc::channel(64); + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let status_clone = Arc::clone(&status); + let nominal_hashrate_ths = config.nominal_hashrate_ths; + + tokio::spawn(async move { + bzm2_thread_actor( + command_rx, + event_tx, + status_clone, + reader, + writer, + control, + config, + ) + .await; + }); + + Self { + name, + command_tx, + event_rx: Some(event_rx), + capabilities: HashThreadCapabilities { + hashrate_estimate: HashRate::from_terahashes(nominal_hashrate_ths), + }, + status, + } + } + + pub fn shutdown_handle(&self) -> Bzm2ThreadHandle { + Bzm2ThreadHandle { + command_tx: self.command_tx.clone(), + } + } +} + +#[async_trait] +impl HashThread for Bzm2Thread { + fn name(&self) -> &str { + &self.name + } + + fn capabilities(&self) -> &HashThreadCapabilities { + &self.capabilities + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::UpdateTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::ReplaceTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + async fn go_idle(&mut self) -> Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::GoIdle { response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("thread dropped response".into()))? + } + + fn take_event_receiver(&mut self) -> Option> { + self.event_rx.take() + } + + fn status(&self) -> HashThreadStatus { + self.status.read().unwrap().clone() + } +} + +async fn bzm2_thread_actor( + mut command_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + status: Arc>, + mut reader: SerialReader, + mut writer: SerialWriter, + control: SerialControl, + config: Bzm2ThreadConfig, +) { + if let Err(err) = control.set_baud_rate(config.baud_rate) { + warn!(path = %config.serial_path, error = %err, "Failed to set BZM2 baud rate"); + } + + let _ = event_tx + .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) + .await; + + let mut engine_layout = Bzm2EngineLayout::default(); + let mut parser = TdmFrameParser::new(config.dts_vs_generation); + let mut current_task: Option = None; + let mut engine_dispatches: HashMap = HashMap::new(); + let mut base_sequence: u8 = 0; + let mut dispatch_tick = tokio::time::interval(config.dispatch_interval); + dispatch_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ntime_tick = tokio::time::interval(Duration::from_secs(1)); + ntime_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut status_tick = tokio::time::interval(Duration::from_secs(5)); + status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut read_buf = [0u8; 4096]; + let mut dts_vs_configured = false; + let mut runtime_measurements = ThreadRuntimeMeasurementState::new(); + + loop { + tokio::select! { + Some(command) = command_rx.recv() => { + match command { + ThreadCommand::UpdateTask { new_task, response_tx } => { + let old = current_task.replace(new_task); + if let Some(ref task) = current_task { + if let Err(err) = dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_layout, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + base_sequence = base_sequence.wrapping_add(1); + set_active(&status, true, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::ReplaceTask { new_task, response_tx } => { + engine_dispatches.clear(); + let old = current_task.replace(new_task); + if let Some(ref task) = current_task { + if let Err(err) = dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_layout, + &mut engine_dispatches, + &config, + ).await { + let _ = response_tx.send(Err(err)); + continue; + } + base_sequence = base_sequence.wrapping_add(1); + set_active(&status, true, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::GoIdle { response_tx } => { + engine_dispatches.clear(); + let old = current_task.take(); + set_active(&status, false, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + let _ = response_tx.send(Ok(old)); + } + ThreadCommand::QueryNoop { asic, response_tx } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + query_noop(&mut reader, &mut writer, asic).await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::QueryLoopback { + asic, + payload, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + query_loopback(&mut reader, &mut writer, asic, &payload).await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::QueryClockReport { asic, response_tx } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { query_clock_report(&mut reader, &mut writer, asic).await }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::ReadRegister { + asic, + engine_address, + offset, + count, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + read_register( + &mut reader, + &mut writer, + asic, + engine_address, + offset, + count, + ) + .await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::WriteRegister { + asic, + engine_address, + offset, + value, + response_tx, + } => { + let result = run_idle_uart_diagnostic( + current_task.is_some(), + dts_vs_configured, + || async { + write_register( + &mut writer, + asic, + engine_address, + offset, + &value, + ) + .await + }, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::QueryDtsVs { asic, response_tx } => { + let result = query_dts_vs_telemetry( + asic, + &mut reader, + &mut writer, + &mut parser, + &engine_dispatches, + &engine_layout, + &config, + &status, + &event_tx, + &mut runtime_measurements, + &mut dts_vs_configured, + ).await; + let _ = response_tx.send(result); + } + ThreadCommand::DiscoverEngineMap { + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + response_tx, + } => { + if current_task.is_some() { + let _ = response_tx.send(Err(HashThreadError::DiagnosticsFailed( + "BZM2 engine discovery requires the thread to be idle".into(), + ))); + continue; + } + let result = discover_engine_map_stream( + &mut reader, + &mut writer, + asic, + tdm_prediv_raw, + tdm_counter, + timeout, + ) + .await + .map(|discovery| { + engine_layout = Bzm2EngineLayout::from_active_coordinates( + discovery.present.iter().map(|engine| (engine.row, engine.col)), + ); + discovery + }) + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string())); + let _ = response_tx.send(result); + } + ThreadCommand::QueryRuntimeMetrics { response_tx } => { + let _ = response_tx.send(Ok(runtime_measurements.snapshot_at(Instant::now()))); + } + ThreadCommand::Shutdown => break, + } + } + read_result = reader.read(&mut read_buf) => { + match read_result { + Ok(0) => break, + Ok(n) => { + let mut should_shutdown = false; + for frame in parser.push(&read_buf[..n]) { + match frame { + TdmFrame::Result(frame) => { + handle_result_frame( + &frame, + &engine_dispatches, + &engine_layout, + &config, + &status, + &event_tx, + &mut runtime_measurements, + ) + .await; + } + TdmFrame::DtsVs(frame) => { + should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).await; + if should_shutdown { + break; + } + } + TdmFrame::Register(_) | TdmFrame::Noop(_) => {} + } + } + if should_shutdown { + break; + } + } + Err(err) => { + error!(path = %config.serial_path, error = %err, "BZM2 serial read failed"); + record_hardware_error(&status); + break; + } + } + } + _ = dispatch_tick.tick(), if current_task.is_some() => { + if let Some(ref task) = current_task { + match dispatch_task_to_board( + &mut writer, + task, + base_sequence, + &engine_layout, + &mut engine_dispatches, + &config, + ).await { + Ok(()) => { + base_sequence = base_sequence.wrapping_add(1); + } + Err(err) => { + error!(path = %config.serial_path, error = %err, "BZM2 dispatch failed"); + record_hardware_error(&status); + } + } + } + } + _ = ntime_tick.tick(), if current_task.is_some() => { + if let Some(ref mut task) = current_task { + task.ntime = task.ntime.wrapping_add(1); + } + } + _ = status_tick.tick() => { + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot_status(&status))).await; + } + } + } + + set_active(&status, false, config.nominal_hashrate_ths); + refresh_status_hashrate( + &status, + &mut runtime_measurements, + config.nominal_hashrate_ths, + ); + let _ = event_tx + .send(HashThreadEvent::StatusUpdate(snapshot_status(&status))) + .await; +} + +async fn handle_dts_vs_frame( + frame: &TdmDtsVsFrame, + config: &Bzm2ThreadConfig, + status: &Arc>, + event_tx: &mpsc::Sender, +) -> bool { + if let Some(update) = build_dts_vs_telemetry_update(frame, config) { + if let Some(reading) = update.temperatures.first() { + set_temperature(status, reading.temperature_c); + } + let _ = event_tx + .send(HashThreadEvent::TelemetryUpdate(update)) + .await; + } + + match frame { + TdmDtsVsFrame::Gen1(frame) => { + trace!( + path = %config.serial_path, + asic = frame.asic, + voltage = frame.voltage, + voltage_enabled = frame.voltage_enabled, + thermal_tune_code = frame.thermal_tune_code, + thermal_validity = frame.thermal_validity, + thermal_enabled = frame.thermal_enabled, + "BZM2 DTS/VS telemetry frame" + ); + false + } + TdmDtsVsFrame::Gen2(frame) => { + trace!( + path = %config.serial_path, + asic = frame.asic, + thermal_trip = frame.thermal_trip_status, + thermal_fault = frame.thermal_fault, + voltage_fault = frame.voltage_fault, + voltage_shutdown = frame.voltage_shutdown_status, + thermal_tune_code = frame.thermal_tune_code, + ch0_voltage = frame.ch0_voltage, + ch1_voltage = frame.ch1_voltage, + ch2_voltage = frame.ch2_voltage, + "BZM2 DTS/VS gen2 telemetry frame" + ); + + if frame.thermal_trip_status + || frame.thermal_fault + || frame.voltage_fault + || frame.voltage_shutdown_status + { + warn!( + path = %config.serial_path, + asic = frame.asic, + thermal_trip = frame.thermal_trip_status, + thermal_fault = frame.thermal_fault, + voltage_fault = frame.voltage_fault, + voltage_shutdown = frame.voltage_shutdown_status, + "BZM2 hardware fault reported by DTS/VS frame" + ); + record_hardware_error(status); + return true; + } + false + } + } +} + +async fn run_idle_uart_diagnostic( + thread_active: bool, + dts_vs_configured: bool, + operation: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + if thread_active { + return Err(HashThreadError::DiagnosticsFailed( + "BZM2 UART diagnostics require the thread to be idle".into(), + )); + } + if dts_vs_configured { + return Err(HashThreadError::DiagnosticsFailed( + "BZM2 UART diagnostics require DTS/VS streaming to be inactive".into(), + )); + } + operation().await +} + +async fn write_register( + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + value: &[u8], +) -> Result<(), HashThreadError> { + writer + .write_all(&encode_write_register(asic, engine_address, offset, value)) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + Ok(()) +} + +async fn read_local_reg_u8( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let value = read_register(reader, writer, asic, super::uart::NOTCH_REG, offset, 1).await?; + value + .first() + .copied() + .ok_or_else(|| HashThreadError::DiagnosticsFailed("short local register response".into())) +} + +async fn read_local_reg_u32( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let value = read_register(reader, writer, asic, super::uart::NOTCH_REG, offset, 4).await?; + let bytes: [u8; 4] = value + .as_slice() + .try_into() + .map_err(|_| HashThreadError::DiagnosticsFailed("short local register response".into()))?; + Ok(u32::from_le_bytes(bytes)) +} + +async fn read_register( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, +) -> Result, HashThreadError> { + let request = encode_read_register(asic, engine_address, offset, count); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let expected = count as usize + 2; + let mut response = vec![0u8; expected]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(response[2..].to_vec()) +} + +async fn read_pll_status( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + pll: Bzm2Pll, +) -> Result { + let (_, _, enable_reg, misc_reg) = pll.register_block(); + let enable = read_local_reg_u32(reader, writer, asic, enable_reg).await?; + let misc = read_local_reg_u32(reader, writer, asic, misc_reg).await?; + Ok(Bzm2PllStatus { + pll, + enable_register: enable, + misc_register: misc, + enabled: (enable & 0x1) != 0, + locked: (enable & 0x4) != 0, + }) +} + +async fn read_dll_status( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + dll: Bzm2Dll, +) -> Result { + let (control2_reg, _, _, control5_reg, coarse_reg) = dll.registers(); + let control2 = read_local_reg_u8(reader, writer, asic, control2_reg).await?; + let control5 = read_local_reg_u8(reader, writer, asic, control5_reg).await?; + let coarse_raw = read_local_reg_u8(reader, writer, asic, coarse_reg).await?; + let fincon = read_local_reg_u8(reader, writer, asic, dll.fincon_register()).await?; + + Ok(Bzm2DllStatus { + dll, + control2, + control5, + coarsecon: (coarse_raw >> 5) & 0x7, + fincon, + freeze_valid: (control2 & 0x2) != 0, + locked: (control5 & 0x2) != 0, + fincon_valid: fincon_is_valid(fincon), + }) +} + +async fn query_clock_report( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, +) -> Result { + Ok(Bzm2ClockDebugReport { + asic, + pll0: read_pll_status(reader, writer, asic, Bzm2Pll::Pll0).await?, + pll1: read_pll_status(reader, writer, asic, Bzm2Pll::Pll1).await?, + dll0: read_dll_status(reader, writer, asic, Bzm2Dll::Dll0).await?, + dll1: read_dll_status(reader, writer, asic, Bzm2Dll::Dll1).await?, + }) +} + +async fn query_noop( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, +) -> Result<[u8; 3], HashThreadError> { + let request = encode_noop(asic); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let mut response = [0u8; 5]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) +} + +async fn query_loopback( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + payload: &[u8], +) -> Result, HashThreadError> { + let request = encode_loopback(asic, payload); + writer + .write_all(&request) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + writer + .flush() + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + + let expected = payload.len() + 2; + let mut response = vec![0u8; expected]; + reader + .read_exact(&mut response) + .await + .map_err(|err| HashThreadError::DiagnosticsFailed(err.to_string()))?; + validate_response_header(asic, OPCODE_UART_LOOPBACK, &response)?; + Ok(response[2..].to_vec()) +} + +fn validate_response_header( + expected_asic: u8, + expected_opcode: u8, + response: &[u8], +) -> Result<(), HashThreadError> { + if response.len() < 2 { + return Err(HashThreadError::DiagnosticsFailed(format!( + "short UART response: expected at least 2 bytes, got {}", + response.len() + ))); + } + let actual_asic = response[0]; + let actual_opcode = response[1]; + if actual_asic != expected_asic || actual_opcode != expected_opcode { + return Err(HashThreadError::DiagnosticsFailed(format!( + "unexpected UART response header: expected asic {expected_asic:#x} opcode {expected_opcode:#x}, got asic {actual_asic:#x} opcode {actual_opcode:#x}" + ))); + } + Ok(()) +} + +async fn query_dts_vs_telemetry( + asic: u8, + reader: &mut SerialReader, + writer: &mut SerialWriter, + parser: &mut TdmFrameParser, + engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, + config: &Bzm2ThreadConfig, + status: &Arc>, + event_tx: &mpsc::Sender, + runtime_measurements: &mut ThreadRuntimeMeasurementState, + dts_vs_configured: &mut bool, +) -> Result { + if !*dts_vs_configured { + configure_dts_vs_stream(writer, reader, &Bzm2DtsVsConfig::default()) + .await + .map_err(|err| HashThreadError::TelemetryQueryFailed(err.to_string()))?; + *dts_vs_configured = true; + } + + let deadline = tokio::time::Instant::now() + DEFAULT_DTS_VS_QUERY_TIMEOUT; + let mut read_buf = [0u8; 512]; + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(HashThreadError::TelemetryQueryFailed(format!( + "timed out waiting for DTS/VS frame from ASIC {asic:#x}" + ))); + } + let remaining = deadline.saturating_duration_since(now); + let read = tokio::time::timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| { + HashThreadError::TelemetryQueryFailed(format!( + "timed out waiting for DTS/VS frame from ASIC {asic:#x}" + )) + }) + .and_then(|result| { + result.map_err(|err| HashThreadError::TelemetryQueryFailed(err.to_string())) + })?; + if read == 0 { + return Err(HashThreadError::TelemetryQueryFailed( + "serial stream closed while waiting for DTS/VS data".into(), + )); + } + + for frame in parser.push(&read_buf[..read]) { + match frame { + TdmFrame::Result(frame) => { + handle_result_frame( + &frame, + engine_dispatches, + engine_layout, + config, + status, + event_tx, + runtime_measurements, + ) + .await; + } + TdmFrame::DtsVs(frame) => { + let frame_asic = dts_vs_frame_asic(&frame); + let update = + build_dts_vs_telemetry_update(&frame, config).ok_or_else(|| { + HashThreadError::TelemetryQueryFailed( + "failed to build DTS/VS telemetry update".into(), + ) + })?; + let should_shutdown = + handle_dts_vs_frame(&frame, config, status, event_tx).await; + if should_shutdown { + return Err(HashThreadError::TelemetryQueryFailed( + "DTS/VS query observed a hardware fault".into(), + )); + } + if frame_asic == asic { + return Ok(update); + } + } + TdmFrame::Register(_) | TdmFrame::Noop(_) => {} + } + } + } +} + +async fn dispatch_task_to_board( + writer: &mut SerialWriter, + task: &HashTask, + base_sequence: u8, + engine_layout: &Bzm2EngineLayout, + engine_dispatches: &mut HashMap, + config: &Bzm2ThreadConfig, +) -> Result<(), HashThreadError> { + let merkle_root = match &task.template.merkle_root { + MerkleRootKind::Fixed(root) => *root, + MerkleRootKind::Computed(_) => { + let en2 = task.en2.as_ref().ok_or_else(|| { + HashThreadError::WorkAssignmentFailed( + "BZM2 requires extranonce2 for computed merkle root".into(), + ) + })?; + task.template.compute_merkle_root(en2).map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!( + "BZM2 merkle root computation failed: {err}" + )) + })? + } + }; + + let versions = compute_micro_versions(task); + let midstates = versions.map(|version| compute_midstate(task, merkle_root, version)); + let header_bytes = serialize(&BlockHeader { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce: 0, + }); + let merkle_root_residue = u32::from_le_bytes(header_bytes[64..68].try_into().unwrap()); + let lead_zeros = leading_zero_threshold(task.share_target).saturating_sub(32); + let timestamp_count = config.timestamp_count; + let bits = task.template.bits.to_consensus(); + + for &(row, col) in engine_layout.active_coordinates() { + let engine_address = logical_engine_address(row, col); + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_ZEROS_TO_FIND, + &[lead_zeros], + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write lead zeros: {err}")) + })?; + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_TIMESTAMP_COUNT, + &[timestamp_count], + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!( + "Failed to write timestamp count: {err}" + )) + })?; + + writer + .write_all(&encode_write_register( + BROADCAST_ASIC, + engine_address, + ENGINE_REG_TARGET, + &bits.to_le_bytes(), + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write target bits: {err}")) + })?; + + let seq_start = (base_sequence % 2) * 4; + for (micro_job_id, midstate) in midstates.iter().enumerate() { + let job_control = if micro_job_id == 3 { 3 } else { 0 }; + writer + .write_all(&encode_write_job( + BROADCAST_ASIC, + engine_address, + midstate, + merkle_root_residue, + task.ntime, + seq_start + micro_job_id as u8, + job_control, + )) + .await + .map_err(|err| { + HashThreadError::WorkAssignmentFailed(format!("Failed to write job: {err}")) + })?; + } + + engine_dispatches.insert( + engine_layout.logical_engine_id(row, col).unwrap(), + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence, + }, + ); + } + + Ok(()) +} + +async fn handle_result_frame( + frame: &protocol::TdmResultFrame, + engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, + config: &Bzm2ThreadConfig, + status: &Arc>, + event_tx: &mpsc::Sender, + runtime_measurements: &mut ThreadRuntimeMeasurementState, +) { + let Some((share, target_diff, engine_id)) = + reconstruct_share_from_result(frame, engine_dispatches, engine_layout, config) + else { + return; + }; + + let share_tx = { + let dispatch = engine_dispatches + .get(&engine_id) + .expect("dispatch must exist for reconstructed share"); + dispatch.task.share_tx.clone() + }; + + runtime_measurements.record_at(Instant::now(), frame.asic, frame.row(), share.expected_work); + refresh_status_hashrate(status, runtime_measurements, config.nominal_hashrate_ths); + + if share_tx.send(share.clone()).await.is_ok() { + let snapshot = { + let mut lock = status.write().unwrap(); + lock.chip_shares_found += 1; + lock.clone() + }; + let _ = event_tx.send(HashThreadEvent::StatusUpdate(snapshot)).await; + } + + trace!( + engine_id, + seq = frame.sequence_id, + nonce = format!("{:#010x}", share.nonce), + hash = %share.hash, + hash_diff = %Difficulty::from_hash(&share.hash), + target_diff = %target_diff, + "BZM2 share accepted" + ); +} + +fn reconstruct_share_from_result( + frame: &protocol::TdmResultFrame, + engine_dispatches: &HashMap, + engine_layout: &Bzm2EngineLayout, + config: &Bzm2ThreadConfig, +) -> Option<(Share, Difficulty, u16)> { + if !frame.nonce_valid() { + return None; + } + + let engine_id = engine_layout.logical_engine_id(frame.row(), frame.col())?; + let dispatch = engine_dispatches.get(&engine_id)?; + + let hardware_base_sequence = frame.sequence_id / 4; + if (dispatch.base_sequence % 2) != hardware_base_sequence { + return None; + } + + let micro_job_id = (frame.sequence_id % 4) as usize; + let version = dispatch.versions[micro_job_id]; + let ntime_offset = u32::from(config.timestamp_count.saturating_sub(frame.reported_time)); + let ntime = dispatch.task.ntime.wrapping_add(ntime_offset); + let nonce = frame.nonce.wrapping_sub(config.nonce_gap); + + let header = BlockHeader { + version, + prev_blockhash: dispatch.task.template.prev_blockhash, + merkle_root: dispatch.merkle_root, + time: ntime, + bits: dispatch.task.template.bits, + nonce, + }; + let hash = header.block_hash(); + + if !dispatch.task.share_target.is_met_by(hash) { + return None; + } + + Some(( + Share { + nonce, + hash, + version, + ntime, + extranonce2: dispatch.task.en2, + expected_work: dispatch.task.share_target.to_work(), + }, + Difficulty::from_target(dispatch.task.share_target), + engine_id, + )) +} + +fn compute_micro_versions(task: &HashTask) -> [bitcoin::block::Version; 4] { + let candidates = [0u16, 2, 4, 8]; + let mut versions = [task.template.version.base(); 4]; + + for (slot, candidate) in candidates.into_iter().enumerate() { + let gp_bits = GeneralPurposeBits::new(candidate.to_be_bytes()); + versions[slot] = task + .template + .version + .apply_gp_bits(&gp_bits) + .unwrap_or_else(|_| task.template.version.base()); + } + + versions +} + +fn compute_midstate( + task: &HashTask, + merkle_root: bitcoin::TxMerkleNode, + version: bitcoin::block::Version, +) -> [u8; 32] { + let header_bytes = serialize(&BlockHeader { + version, + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce: 0, + }); + + let mut engine = sha256::HashEngine::default(); + engine.input(&header_bytes[..64]); + engine.midstate().to_byte_array() +} + +fn snapshot_status(status: &Arc>) -> HashThreadStatus { + status.read().unwrap().clone() +} + +fn set_active(status: &Arc>, is_active: bool, nominal_hashrate_ths: f64) { + let mut lock = status.write().unwrap(); + lock.is_active = is_active; + lock.hashrate = if is_active { + HashRate::from_terahashes(nominal_hashrate_ths) + } else { + HashRate::default() + }; +} + +fn refresh_status_hashrate( + status: &Arc>, + runtime_measurements: &mut ThreadRuntimeMeasurementState, + nominal_hashrate_ths: f64, +) { + let now = Instant::now(); + let mut lock = status.write().unwrap(); + lock.hashrate = + runtime_measurements.current_hashrate(now, lock.is_active, nominal_hashrate_ths); +} + +fn record_hardware_error(status: &Arc>) { + let mut lock = status.write().unwrap(); + lock.hardware_errors = lock.hardware_errors.saturating_add(1); +} + +fn set_temperature(status: &Arc>, temperature_c: Option) { + let mut lock = status.write().unwrap(); + lock.temperature_c = temperature_c; +} + +fn build_dts_vs_telemetry_update( + frame: &TdmDtsVsFrame, + config: &Bzm2ThreadConfig, +) -> Option { + let prefix = sensor_prefix(&config.serial_path); + match frame { + TdmDtsVsFrame::Gen1(frame) => Some(HashThreadTelemetryUpdate { + temperatures: Vec::new(), + powers: vec![HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.voltage)), + current_a: None, + power_w: None, + }], + }), + TdmDtsVsFrame::Gen2(frame) => Some(HashThreadTelemetryUpdate { + temperatures: vec![HashThreadTemperatureReading { + name: format!("{prefix}-asic-{}-dts", frame.asic), + temperature_c: (frame.thermal_enabled && frame.thermal_validity) + .then(|| legacy_tune_code_to_temperature_c(frame.thermal_tune_code)), + }], + powers: vec![ + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch0", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch0_voltage)), + current_a: None, + power_w: None, + }, + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch1", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch1_voltage)), + current_a: None, + power_w: None, + }, + HashThreadPowerReading { + name: format!("{prefix}-asic-{}-vs-ch2", frame.asic), + voltage_v: frame + .voltage_enabled + .then(|| legacy_tune_code_to_voltage_v(frame.ch2_voltage)), + current_a: None, + power_w: None, + }, + ], + }), + } +} + +fn dts_vs_frame_asic(frame: &TdmDtsVsFrame) -> u8 { + match frame { + TdmDtsVsFrame::Gen1(frame) => frame.asic, + TdmDtsVsFrame::Gen2(frame) => frame.asic, + } +} + +fn sensor_prefix(serial_path: &str) -> String { + Path::new(serial_path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or(serial_path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +fn legacy_tune_code_to_temperature_c(tune_code: u16) -> f32 { + let resolution_power = 4096.0_f32; + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / resolution_power)) / 4096.0 +} + +fn legacy_tune_code_to_voltage_v(tune_code: u16) -> f32 { + let resolution_power = 16384.0_f32; + 0.4 * 0.7067 * (6.0 * (tune_code as f32) / 16384.0 - 3.0 / resolution_power - 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::job_source::{GeneralPurposeBits, JobTemplate, VersionTemplate}; + use crate::transport::{SerialConfig, SerialStream}; + use bitcoin::hashes::Hash; + use bitcoin::pow::Target; + use nix::pty::openpty; + use std::collections::HashMap as StdHashMap; + use std::os::unix::io::IntoRawFd; + use tokio::sync::mpsc as tokio_mpsc; + + fn test_task() -> HashTask { + let share_target = Target::from_be_bytes([ + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + ]); + let template = Arc::new(JobTemplate { + id: "bzm2-test".into(), + prev_blockhash: bitcoin::BlockHash::all_zeros(), + version: VersionTemplate::new( + bitcoin::block::Version::from_consensus(0x2000_0000), + GeneralPurposeBits::full(), + ) + .unwrap(), + bits: bitcoin::pow::CompactTarget::from_consensus(0x1d00ffff), + share_target, + time: 1_700_000_000, + merkle_root: MerkleRootKind::Fixed(bitcoin::TxMerkleNode::all_zeros()), + }); + let (share_tx, _share_rx) = tokio_mpsc::channel(4); + HashTask { + template, + en2_range: None, + en2: None, + share_target, + ntime: 1_700_000_000, + share_tx, + } + } + + #[test] + fn midstate_changes_with_micro_job_versions() { + let task = test_task(); + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let a = compute_midstate(&task, merkle_root, versions[0]); + let b = compute_midstate(&task, merkle_root, versions[1]); + assert_ne!(a, b); + } + + #[tokio::test] + async fn dispatch_writes_expected_packet_fanout() { + let pty = openpty(None, None).unwrap(); + let writer_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let reader_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (_reader_a, mut writer, _control_a) = writer_side.split(); + let (mut reader, _writer_b, _control_b) = reader_side.split(); + + let task = test_task(); + let mut engine_dispatches = StdHashMap::new(); + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let engine_coords = vec![(0, 0), (0, 1)]; + let engine_layout = Bzm2EngineLayout::from_active_coordinates(engine_coords.clone()); + + dispatch_task_to_board( + &mut writer, + &task, + 1, + &engine_layout, + &mut engine_dispatches, + &config, + ) + .await + .unwrap(); + + let expected_bytes_per_engine = 8 + 8 + 11 + (48 * 4); + let expected_total = expected_bytes_per_engine * engine_coords.len(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(250); + let mut buf = vec![0u8; 512]; + let mut bytes = Vec::with_capacity(expected_total); + while bytes.len() < expected_total { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!( + !remaining.is_zero(), + "timed out before collecting the full dispatch stream" + ); + let n = tokio::time::timeout(remaining, reader.read(&mut buf)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } + + assert_eq!(bytes.len(), expected_total); + assert_eq!(engine_dispatches.len(), engine_coords.len()); + + let first_packet_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize; + assert_eq!(first_packet_len, 8); + + let last_packet_start = bytes.len() - 48; + assert_eq!( + u16::from_le_bytes([bytes[last_packet_start], bytes[last_packet_start + 1]]) as usize, + 48 + ); + assert_eq!(bytes[last_packet_start + 46], 7); + assert_eq!(bytes[last_packet_start + 47], 3); + } + + #[tokio::test] + async fn parsed_uart_frame_emits_share_and_status_event() { + let mut task = test_task(); + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let engine_layout = Bzm2EngineLayout::default(); + let (row, col) = protocol::default_engine_coordinates()[0]; + let engine_id = engine_layout.logical_engine_id(row, col).unwrap(); + let nonce = 0; + let expected_hash = bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash(); + task.share_target = Difficulty::from_hash(&expected_hash).to_target(); + + let mut engine_dispatches = StdHashMap::new(); + engine_dispatches.insert( + engine_id, + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence: 0, + }, + ); + + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let status = Arc::new(RwLock::new(HashThreadStatus { + hashrate: HashRate::from_terahashes(40.0), + is_active: true, + ..Default::default() + })); + let mut runtime_measurements = ThreadRuntimeMeasurementState::new(); + let (event_tx, mut event_rx) = tokio_mpsc::channel(4); + let (share_tx, mut share_rx) = tokio_mpsc::channel(4); + engine_dispatches.get_mut(&engine_id).unwrap().task.share_tx = share_tx; + + let engine_address = protocol::logical_engine_address(row, col); + let header = ((0x8u16) << 12) | engine_address; + let mut raw = Vec::with_capacity(10); + raw.push(0); + raw.push(protocol::OPCODE_UART_READRESULT); + raw.extend_from_slice(&header.to_be_bytes()); + raw.extend_from_slice(&(nonce + DEFAULT_NONCE_GAP).to_le_bytes()); + raw.push(0); + raw.push(DEFAULT_TIMESTAMP_COUNT); + + let mut parser = protocol::TdmResultParser::default(); + let frames = parser.push(&raw); + assert_eq!(frames.len(), 1); + assert!( + reconstruct_share_from_result(&frames[0], &engine_dispatches, &engine_layout, &config) + .is_some() + ); + + handle_result_frame( + &frames[0], + &engine_dispatches, + &engine_layout, + &config, + &status, + &event_tx, + &mut runtime_measurements, + ) + .await; + + let share = tokio::time::timeout(Duration::from_millis(250), share_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(share.nonce, nonce); + assert_eq!(share.ntime, task.ntime); + assert_eq!(share.version, versions[0]); + assert_eq!(share.hash, expected_hash); + + let status_update = tokio::time::timeout(Duration::from_millis(250), event_rx.recv()) + .await + .unwrap() + .unwrap(); + match status_update { + HashThreadEvent::StatusUpdate(snapshot) => { + assert!(snapshot.is_active); + assert_eq!(snapshot.chip_shares_found, 1); + assert!(u64::from(snapshot.hashrate) > 0); + } + other => panic!("unexpected event: {:?}", other), + } + } + + #[test] + fn runtime_metrics_track_per_asic_and_per_pll_throughput() { + let base = Instant::now(); + let mut runtime = ThreadRuntimeMeasurementState::new(); + let work = bitcoin::pow::Work::from_le_bytes({ + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&1_000u64.to_le_bytes()); + bytes + }); + + runtime.record_at(base, 2, 0, work); + runtime.record_at(base + Duration::from_secs(10), 2, 12, work); + runtime.record_at(base + Duration::from_secs(20), 2, 12, work); + let snapshot = runtime.snapshot_at(base + Duration::from_secs(20)); + + assert_eq!(snapshot.asics.len(), 1); + let asic = &snapshot.asics[0]; + assert_eq!(asic.asic, 2); + assert_eq!(asic.scheduler_share_count, 3); + assert_eq!(asic.throughput_hs, Some(150)); + assert_eq!(asic.plls[0].scheduler_share_count, 1); + assert_eq!(asic.plls[0].throughput_hs, Some(50)); + assert_eq!(asic.plls[1].scheduler_share_count, 2); + assert_eq!(asic.plls[1].throughput_hs, Some(200)); + } + #[tokio::test] + async fn gen2_dts_vs_emits_api_telemetry() { + let config = Bzm2ThreadConfig::new("/dev/ttyUSB0".into(), 5_000_000); + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let (event_tx, mut event_rx) = tokio_mpsc::channel(4); + let frame = TdmDtsVsFrame::Gen2(protocol::TdmDtsVsGen2Frame { + asic: 2, + ch0_voltage: 0x1645, + ch1_voltage: 0x04B4, + ch2_voltage: 0x16AC, + voltage_shutdown_status: false, + voltage_enabled: true, + thermal_tune_code: 0x07A9, + thermal_trip_status: false, + thermal_fault: false, + thermal_validity: true, + thermal_enabled: true, + voltage_fault: false, + dll0_lock: false, + dll1_lock: true, + pll_lock: true, + }); + + let should_shutdown = handle_dts_vs_frame(&frame, &config, &status, &event_tx).await; + assert!(!should_shutdown); + + let update = event_rx.recv().await.unwrap(); + match update { + HashThreadEvent::TelemetryUpdate(update) => { + assert_eq!(update.temperatures.len(), 1); + assert_eq!(update.temperatures[0].name, "ttyUSB0-asic-2-dts"); + let temp = update.temperatures[0].temperature_c.unwrap(); + assert!((temp - legacy_tune_code_to_temperature_c(0x07A9)).abs() < 0.01); + + assert_eq!(update.powers.len(), 3); + assert_eq!(update.powers[0].name, "ttyUSB0-asic-2-vs-ch0"); + assert!( + (update.powers[0].voltage_v.unwrap() - legacy_tune_code_to_voltage_v(0x1645)) + .abs() + < 0.0001 + ); + assert_eq!(update.powers[1].name, "ttyUSB0-asic-2-vs-ch1"); + assert_eq!(update.powers[2].name, "ttyUSB0-asic-2-vs-ch2"); + } + other => panic!("unexpected event: {other:?}"), + } + + let snapshot = status.read().unwrap().clone(); + assert!( + (snapshot.temperature_c.unwrap() - legacy_tune_code_to_temperature_c(0x07A9)).abs() + < 0.01 + ); + } + + #[tokio::test] + async fn gen2_dts_vs_fault_shuts_down_live_thread() { + let pty = openpty(None, None).unwrap(); + let thread_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let host_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (reader, writer, control) = thread_side.split(); + let (_host_reader, mut host_writer, _host_control) = host_side.split(); + + let mut config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + config.dts_vs_generation = protocol::DtsVsGeneration::Gen2; + let mut thread = Bzm2Thread::new("BZM2 test".into(), reader, writer, control, config); + let mut event_rx = thread.take_event_receiver().unwrap(); + + let initial = tokio::time::timeout(Duration::from_millis(250), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(initial, HashThreadEvent::StatusUpdate(_))); + + host_writer + .write_all(&[ + 0x00, + protocol::OPCODE_UART_DTS_VS, + 0xD5, + 0xAB, + 0x34, + 0x12, + 0x45, + 0x96, + 0xA9, + 0xF7, + ]) + .await + .unwrap(); + + let mut saw_fault_status = false; + let closed = tokio::time::timeout(Duration::from_secs(1), async { + while let Some(event) = event_rx.recv().await { + if let HashThreadEvent::StatusUpdate(status) = event { + if !status.is_active && status.hardware_errors >= 1 { + saw_fault_status = true; + } + } + } + }) + .await; + + assert!( + closed.is_ok(), + "thread should exit after DTS/VS hardware fault" + ); + assert!( + saw_fault_status, + "thread should publish a final faulted status" + ); + } + + #[test] + fn reconstructs_share_from_matching_result_frame() { + let mut task = test_task(); + + let merkle_root = bitcoin::TxMerkleNode::all_zeros(); + let versions = compute_micro_versions(&task); + let engine_layout = Bzm2EngineLayout::default(); + let (row, col) = protocol::default_engine_coordinates()[0]; + let engine_id = engine_layout.logical_engine_id(row, col).unwrap(); + let nonce = 0; + let expected_hash = bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash(); + task.share_target = Difficulty::from_hash(&expected_hash).to_target(); + let frame = protocol::TdmResultFrame { + asic: 0, + engine_address: protocol::logical_engine_address(row, col), + status: 0x8, + nonce: nonce + DEFAULT_NONCE_GAP, + sequence_id: 0, + reported_time: DEFAULT_TIMESTAMP_COUNT, + }; + + let mut engine_dispatches = StdHashMap::new(); + engine_dispatches.insert( + engine_id, + EngineDispatch { + task: task.clone(), + merkle_root, + versions, + base_sequence: 0, + }, + ); + + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let (share, target_diff, reconstructed_engine_id) = + reconstruct_share_from_result(&frame, &engine_dispatches, &engine_layout, &config) + .unwrap(); + + assert_eq!(reconstructed_engine_id, engine_id); + assert_eq!(share.nonce, nonce); + assert_eq!(share.ntime, task.ntime); + assert_eq!(share.version, versions[0]); + assert_eq!( + share.hash, + bitcoin::block::Header { + version: versions[0], + prev_blockhash: task.template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: task.template.bits, + nonce, + } + .block_hash() + ); + assert_eq!(target_diff, Difficulty::from_target(task.share_target)); + } + + #[test] + fn runtime_engine_layout_compresses_logical_ids_after_missing_engines() { + let layout = Bzm2EngineLayout::from_active_coordinates([(0, 0), (0, 6), (19, 10)]); + + assert_eq!(layout.active_engine_count(), 3); + assert_eq!(layout.logical_engine_id(0, 0), Some(0)); + assert_eq!(layout.logical_engine_id(0, 6), Some(1)); + assert_eq!(layout.logical_engine_id(19, 10), Some(2)); + assert_eq!(layout.logical_engine_id(0, 1), None); + } + + #[tokio::test] + async fn dispatch_uses_runtime_engine_layout() { + let pty = openpty(None, None).unwrap(); + let writer_side = + SerialStream::from_fd(pty.master.into_raw_fd(), SerialConfig::default()).unwrap(); + let reader_side = + SerialStream::from_fd(pty.slave.into_raw_fd(), SerialConfig::default()).unwrap(); + let (_reader_a, mut writer, _control_a) = writer_side.split(); + let (mut reader, _writer_b, _control_b) = reader_side.split(); + + let task = test_task(); + let mut engine_dispatches = StdHashMap::new(); + let config = Bzm2ThreadConfig::new("/dev/null".into(), 5_000_000); + let engine_layout = Bzm2EngineLayout::from_active_coordinates([(0, 0), (19, 10)]); + + dispatch_task_to_board( + &mut writer, + &task, + 1, + &engine_layout, + &mut engine_dispatches, + &config, + ) + .await + .unwrap(); + + let mut buf = vec![0u8; 512]; + let mut bytes = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(250); + while bytes.len() < (8 + 8 + 11 + (48 * 4)) * engine_layout.active_engine_count() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let n = tokio::time::timeout(remaining, reader.read(&mut buf)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } + + let first_engine = logical_engine_address(0, 0).to_be_bytes(); + let second_engine = logical_engine_address(19, 10).to_be_bytes(); + assert!(bytes.windows(2).any(|window| window == first_engine)); + assert!(bytes.windows(2).any(|window| window == second_engine)); + assert!( + !bytes + .windows(2) + .any(|window| window == logical_engine_address(0, 1).to_be_bytes()) + ); + assert_eq!(engine_dispatches.len(), 2); + assert!(engine_dispatches.contains_key(&0)); + assert!(engine_dispatches.contains_key(&1)); + } +} diff --git a/mujina-miner/src/asic/bzm2/uart.rs b/mujina-miner/src/asic/bzm2/uart.rs new file mode 100644 index 00000000..7ae7681d --- /dev/null +++ b/mujina-miner/src/asic/bzm2/uart.rs @@ -0,0 +1,1070 @@ +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Instant, timeout}; + +use crate::transport::{SerialReader, SerialWriter}; + +use super::protocol::{ + BROADCAST_ASIC, DtsVsGeneration, ENGINE_REG_END_NONCE, OPCODE_UART_LOOPBACK, OPCODE_UART_NOOP, + OPCODE_UART_READREG, TdmDtsVsFrame, TdmFrame, TdmFrameParser, encode_loopback, + encode_multicast_write, encode_noop, encode_read_register, encode_write_job, + encode_write_register, logical_engine_address, physical_engine_coordinates, +}; + +pub const NOTCH_REG: u16 = 0x0fff; +pub const BROADCAST_GROUP_ASIC: u8 = BROADCAST_ASIC; +pub const DEFAULT_DTS_VS_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +pub const DEFAULT_ASIC_ID: u8 = 0xfa; +pub const DEFAULT_NOOP_PROBE_TIMEOUT: Duration = Duration::from_millis(100); + +const LOCAL_REG_ASIC_ID: u8 = 0x0b; +const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; +const LOCAL_REG_SLOW_CLK_DIV: u8 = 0x08; +const LOCAL_REG_UART_TX: u8 = 0x0a; +const LOCAL_REG_SENS_TDM_GAP_CNT: u8 = 0x2d; +const LOCAL_REG_DTS_SRST_PD: u8 = 0x2e; +const LOCAL_REG_DTS_CFG: u8 = 0x2f; +const LOCAL_REG_TEMPSENSOR_TUNE_CODE: u8 = 0x30; +const LOCAL_REG_SENSOR_THRS_CNT: u8 = 0x3c; +const LOCAL_REG_SENSOR_CLK_DIV: u8 = 0x3d; +const LOCAL_REG_VSENSOR_SRST_PD: u8 = 0x3e; +const LOCAL_REG_VSENSOR_CFG: u8 = 0x3f; +const LOCAL_REG_VOLTAGE_SENSOR_ENABLE: u8 = 0x40; +const LOCAL_REG_BANDGAP: u8 = 0x45; + +const THERMAL_SENSOR_RESOLUTION: u8 = 12; +const THERMAL_SENSOR_MODE: u8 = 0; +const VOLTAGE_SENSOR_RESOLUTION: u8 = 14; +const VOLTAGE_SENSOR_CONVERSION_MODE: u8 = 1; +const VOLTAGE_SENSOR_MODE: u8 = 0; +const DISCOVERED_ENGINE_END_NONCE: u32 = 0xffff_fffe; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bzm2DtsVsConfig { + pub tdm_interval: u8, + pub thermal_trip_c: i32, + pub voltage_ch0_shutdown_mv: u32, + pub voltage_ch1_shutdown_mv: u32, +} + +impl Default for Bzm2DtsVsConfig { + fn default() -> Self { + Self { + tdm_interval: 1, + thermal_trip_c: 115, + voltage_ch0_shutdown_mv: 500, + voltage_ch1_shutdown_mv: 500, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Bzm2UartError { + #[error("serial I/O failed: {0}")] + Io(#[from] std::io::Error), + + #[error("short UART response: expected {expected} bytes, got {actual}")] + ShortResponse { expected: usize, actual: usize }, + + #[error( + "unexpected UART response header: expected asic {expected_asic:#x} opcode {expected_opcode:#x}, got asic {actual_asic:#x} opcode {actual_opcode:#x}" + )] + UnexpectedHeader { + expected_asic: u8, + expected_opcode: u8, + actual_asic: u8, + actual_opcode: u8, + }, + + #[error("unexpected NOOP payload from ASIC {asic:#x}: {data:02x?}")] + UnexpectedNoopPayload { asic: u8, data: [u8; 3] }, + + #[error("timed out waiting for NOOP response from ASIC {asic:#x} after {timeout_ms} ms")] + NoopTimeout { asic: u8, timeout_ms: u64 }, + + #[error("timed out waiting for DTS/VS frame from ASIC {asic:#x}")] + DtsVsTimeout { asic: u8 }, + + #[error( + "timed out waiting for TDM register response from ASIC {asic:#x} engine {engine_address:#05x} offset {offset:#04x}" + )] + TdmRegisterTimeout { + asic: u8, + engine_address: u16, + offset: u8, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Bzm2EngineCoordinate { + pub row: u8, + pub col: u8, + pub engine_address: u16, +} + +impl Bzm2EngineCoordinate { + pub fn new(row: u8, col: u8) -> Self { + Self { + row, + col, + engine_address: logical_engine_address(row, col), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bzm2DiscoveredEngineMap { + pub asic: u8, + pub present: Vec, + pub missing: Vec, +} + +impl Bzm2DiscoveredEngineMap { + pub fn present_count(&self) -> usize { + self.present.len() + } + + pub fn missing_count(&self) -> usize { + self.missing.len() + } +} + +/// Low-level BZM2 UART control surface. +/// +/// This controller wraps the legacy BZM2 UART framing in a small, explicit API. +/// It is intended for board bring-up, ASIC diagnostics, and developer tooling. +/// The methods are organized around the three routing modes exposed by the ASIC: +/// +/// - unicast: target one ASIC and one register space address +/// - multicast: target an ASIC and one engine group row +/// - broadcast: target all ASICs on a bus via ASIC id `0xff` +/// +/// Typical usage patterns: +/// +/// ```rust,no_run +/// # async fn demo(mut uart: mujina_miner::asic::bzm2::Bzm2UartController) -> Result<(), Box> { +/// use mujina_miner::asic::bzm2::{Bzm2Pll, Bzm2UartController, NOTCH_REG}; +/// +/// // Unicast: write one ASIC-local register. +/// uart.write_local_reg_u32(0x02, 0x12, 1).await?; +/// +/// // Broadcast: push one local register update to every ASIC on the UART bus. +/// uart.broadcast_local_reg_u32(0x07, 0x1).await?; +/// +/// // Multicast: update all engines in one row group on one ASIC. +/// uart.multicast_write_reg_u8(0x02, 7, 0x49, 60).await?; +/// # Ok(()) } +/// ``` +pub struct Bzm2UartController { + reader: SerialReader, + writer: SerialWriter, +} + +impl Bzm2UartController { + pub fn new(reader: SerialReader, writer: SerialWriter) -> Self { + Self { reader, writer } + } + + pub async fn write_register( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: &[u8], + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_write_register(asic, engine_address, offset, value)) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn write_register_u8( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_register(asic, engine_address, offset, &[value]) + .await + } + + pub async fn write_register_u32( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_register(asic, engine_address, offset, &value.to_le_bytes()) + .await + } + + pub async fn write_local_reg_u8( + &mut self, + asic: u8, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_register_u8(asic, NOTCH_REG, offset, value).await + } + + pub async fn write_local_reg_u32( + &mut self, + asic: u8, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_register_u32(asic, NOTCH_REG, offset, value) + .await + } + + pub async fn broadcast_local_reg_u8( + &mut self, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.write_local_reg_u8(BROADCAST_ASIC, offset, value).await + } + + pub async fn broadcast_local_reg_u32( + &mut self, + offset: u8, + value: u32, + ) -> Result<(), Bzm2UartError> { + self.write_local_reg_u32(BROADCAST_ASIC, offset, value) + .await + } + + /// Program the next ASIC still responding on the default chain id. + pub async fn assign_default_asic_id(&mut self, new_id: u8) -> Result<(), Bzm2UartError> { + self.write_local_reg_u32(DEFAULT_ASIC_ID, LOCAL_REG_ASIC_ID, new_id as u32) + .await + } + + pub async fn multicast_write_register( + &mut self, + asic: u8, + group: u16, + offset: u8, + value: &[u8], + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_multicast_write(asic, group, offset, value)) + .await?; + self.writer.flush().await?; + Ok(()) + } + + pub async fn multicast_write_reg_u8( + &mut self, + asic: u8, + group: u16, + offset: u8, + value: u8, + ) -> Result<(), Bzm2UartError> { + self.multicast_write_register(asic, group, offset, &[value]) + .await + } + + pub async fn read_register( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + ) -> Result, Bzm2UartError> { + let request = encode_read_register(asic, engine_address, offset, count); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let expected = count as usize + 2; + let mut response = vec![0u8; expected]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(response[2..].to_vec()) + } + + pub async fn read_register_u8( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + ) -> Result { + Ok(self.read_register(asic, engine_address, offset, 1).await?[0]) + } + + pub async fn read_register_u32( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + ) -> Result { + let data = self.read_register(asic, engine_address, offset, 4).await?; + Ok(u32::from_le_bytes(data.try_into().unwrap())) + } + + pub async fn read_local_reg_u8(&mut self, asic: u8, offset: u8) -> Result { + self.read_register_u8(asic, NOTCH_REG, offset).await + } + + pub async fn read_local_reg_u32(&mut self, asic: u8, offset: u8) -> Result { + self.read_register_u32(asic, NOTCH_REG, offset).await + } + + pub async fn noop(&mut self, asic: u8) -> Result<[u8; 3], Bzm2UartError> { + let request = encode_noop(asic); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let mut response = [0u8; 5]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) + } + + pub async fn noop_with_timeout( + &mut self, + asic: u8, + wait: Duration, + ) -> Result<[u8; 3], Bzm2UartError> { + let request = encode_noop(asic); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let mut response = [0u8; 5]; + timeout(wait, self.reader.read_exact(&mut response)) + .await + .map_err(|_| Bzm2UartError::NoopTimeout { + asic, + timeout_ms: wait.as_millis().min(u128::from(u64::MAX)) as u64, + })??; + validate_response_header(asic, OPCODE_UART_NOOP, &response)?; + Ok(response[2..5].try_into().unwrap()) + } + + pub async fn verify_noop_bz2(&mut self, asic: u8) -> Result<(), Bzm2UartError> { + let data = self.noop(asic).await?; + if data == *b"BZ2" { + Ok(()) + } else { + Err(Bzm2UartError::UnexpectedNoopPayload { asic, data }) + } + } + + pub async fn verify_noop_bz2_with_timeout( + &mut self, + asic: u8, + wait: Duration, + ) -> Result<(), Bzm2UartError> { + let data = self.noop_with_timeout(asic, wait).await?; + if data == *b"BZ2" { + Ok(()) + } else { + Err(Bzm2UartError::UnexpectedNoopPayload { asic, data }) + } + } + + /// Enumerate a fresh chain by assigning ids to devices that still answer on + /// the documented default ASIC id `0xFA`. + pub async fn enumerate_chain( + &mut self, + max_asics: u8, + start_id: u8, + ) -> Result, Bzm2UartError> { + self.enumerate_chain_with_timeout(max_asics, start_id, DEFAULT_NOOP_PROBE_TIMEOUT) + .await + } + + /// Enumerate a fresh chain using a bounded NOOP probe so the walk can stop + /// cleanly when the last default-id device has been assigned. + pub async fn enumerate_chain_with_timeout( + &mut self, + max_asics: u8, + start_id: u8, + probe_timeout: Duration, + ) -> Result, Bzm2UartError> { + let mut assigned = Vec::new(); + for offset in 0..max_asics { + let next_id = start_id.saturating_add(offset); + if self + .verify_noop_bz2_with_timeout(DEFAULT_ASIC_ID, probe_timeout) + .await + .is_err() + { + break; + } + self.assign_default_asic_id(next_id).await?; + self.verify_noop_bz2(next_id).await?; + assigned.push(next_id); + } + Ok(assigned) + } + + pub async fn set_tdm_enabled( + &mut self, + prediv_raw: u32, + counter: u8, + enable: bool, + ) -> Result<(), Bzm2UartError> { + set_tdm_enabled_stream(&mut self.writer, prediv_raw, counter, enable).await + } + + pub async fn enable_tdm(&mut self, prediv_raw: u32, counter: u8) -> Result<(), Bzm2UartError> { + self.set_tdm_enabled(prediv_raw, counter, true).await + } + + pub async fn disable_tdm(&mut self, prediv_raw: u32, counter: u8) -> Result<(), Bzm2UartError> { + self.set_tdm_enabled(prediv_raw, counter, false).await + } + + pub async fn read_register_tdm_sync( + &mut self, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + wait: Duration, + ) -> Result, Bzm2UartError> { + read_register_tdm_sync_stream( + &mut self.reader, + &mut self.writer, + asic, + engine_address, + offset, + count, + wait, + ) + .await + } + + pub async fn detect_engine( + &mut self, + asic: u8, + row: u8, + col: u8, + wait: Duration, + ) -> Result { + detect_engine_stream(&mut self.reader, &mut self.writer, asic, row, col, wait).await + } + + pub async fn discover_engine_map( + &mut self, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + wait: Duration, + ) -> Result { + discover_engine_map_stream( + &mut self.reader, + &mut self.writer, + asic, + tdm_prediv_raw, + tdm_counter, + wait, + ) + .await + } + + pub async fn loopback(&mut self, asic: u8, payload: &[u8]) -> Result, Bzm2UartError> { + let request = encode_loopback(asic, payload); + self.writer.write_all(&request).await?; + self.writer.flush().await?; + + let expected = payload.len() + 2; + let mut response = vec![0u8; expected]; + self.reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_LOOPBACK, &response)?; + Ok(response[2..].to_vec()) + } + + pub async fn write_job( + &mut self, + asic: u8, + engine_address: u16, + midstate: &[u8; 32], + merkle_root_residue: u32, + ntime: u32, + sequence_id: u8, + job_control: u8, + ) -> Result<(), Bzm2UartError> { + self.writer + .write_all(&encode_write_job( + asic, + engine_address, + midstate, + merkle_root_residue, + ntime, + sequence_id, + job_control, + )) + .await?; + self.writer.flush().await?; + Ok(()) + } + + /// Enable DTS/VS reporting using the legacy local-register sequence. + pub async fn enable_dts_vs(&mut self, config: Bzm2DtsVsConfig) -> Result<(), Bzm2UartError> { + configure_dts_vs_stream(&mut self.writer, &mut self.reader, &config).await + } + + /// Read the next DTS/VS frame for a specific ASIC after ensuring DTS/VS is enabled. + pub async fn query_dts_vs( + &mut self, + asic: u8, + generation: DtsVsGeneration, + config: Bzm2DtsVsConfig, + timeout: Duration, + ) -> Result { + self.enable_dts_vs(config).await?; + read_dts_vs_frame_stream(&mut self.reader, generation, asic, timeout).await + } +} + +pub async fn configure_dts_vs_stream( + writer: &mut SerialWriter, + reader: &mut SerialReader, + config: &Bzm2DtsVsConfig, +) -> Result<(), Bzm2UartError> { + // Enable thermal and voltage sensor messages on the UART TDM path. + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_UART_TX, 0x0f).await?; + + // Legacy reference clock setup: 50 MHz reference, 6.25 MHz sensor clocks. + let slow_clk_div = 2u32; + let sensor_clk_div = 8u32; + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_SLOW_CLK_DIV, slow_clk_div).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENSOR_CLK_DIV, + (sensor_clk_div << 5) | sensor_clk_div, + ) + .await?; + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_DTS_SRST_PD, 1 << 8).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENS_TDM_GAP_CNT, + config.tdm_interval as u32, + ) + .await?; + + let cfg0_ts_resolution = match THERMAL_SENSOR_RESOLUTION { + 10 => 1, + 8 => 2, + _ => 0, + }; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_DTS_CFG, + ((cfg0_ts_resolution as u32) << 5) | THERMAL_SENSOR_MODE as u32, + ) + .await?; + + let thermal_threshold_cnt = 10u32; + let voltage_ch0_threshold_cnt = 10u32; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_SENSOR_THRS_CNT, + (thermal_threshold_cnt << 16) | voltage_ch0_threshold_cnt, + ) + .await?; + + let thermal_trip_code = legacy_temperature_c_to_tune_code(config.thermal_trip_c); + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_TEMPSENSOR_TUNE_CODE, + 0x8001 | (thermal_trip_code << 1), + ) + .await?; + + let bandgap = read_local_reg_u32_raw(reader, writer, BROADCAST_ASIC, LOCAL_REG_BANDGAP).await?; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_BANDGAP, + (bandgap & !0x0f) | 0x03, + ) + .await?; + + write_local_reg_u32_raw(writer, BROADCAST_ASIC, LOCAL_REG_VSENSOR_SRST_PD, 1 << 8).await?; + + let cfg0_vs_resolution = match VOLTAGE_SENSOR_RESOLUTION { + 12 => 1, + 10 => 2, + 8 => 3, + _ => 0, + }; + let gap_cnt = 8u32; + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_VSENSOR_CFG, + (gap_cnt << 28) + | ((VOLTAGE_SENSOR_CONVERSION_MODE as u32) << 24) + | ((cfg0_vs_resolution as u32) << 5) + | VOLTAGE_SENSOR_MODE as u32, + ) + .await?; + + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_VOLTAGE_SENSOR_ENABLE, + (legacy_voltage_mv_to_tune_code(config.voltage_ch1_shutdown_mv) << 16) + | (legacy_voltage_mv_to_tune_code(config.voltage_ch0_shutdown_mv) << 1) + | 1, + ) + .await?; + + Ok(()) +} + +pub async fn set_tdm_enabled_stream( + writer: &mut SerialWriter, + prediv_raw: u32, + counter: u8, + enable: bool, +) -> Result<(), Bzm2UartError> { + write_local_reg_u32_raw( + writer, + BROADCAST_ASIC, + LOCAL_REG_UART_TDM_CTL, + encode_tdm_control(prediv_raw, counter, enable), + ) + .await +} + +pub async fn read_register_tdm_sync_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + wait: Duration, +) -> Result, Bzm2UartError> { + let request = encode_read_register(asic, engine_address, offset, count); + writer.write_all(&request).await?; + writer.flush().await?; + + let deadline = Instant::now() + wait; + let mut parser = TdmFrameParser::default(); + parser.expect_read_register_bytes(asic, count as usize); + let mut read_buf = [0u8; 256]; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(Bzm2UartError::TdmRegisterTimeout { + asic, + engine_address, + offset, + }); + } + let remaining = deadline.saturating_duration_since(now); + let read = timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| Bzm2UartError::TdmRegisterTimeout { + asic, + engine_address, + offset, + })??; + if read == 0 { + return Err(Bzm2UartError::ShortResponse { + expected: 1, + actual: 0, + }); + } + for frame in parser.push(&read_buf[..read]) { + if let TdmFrame::Register(frame) = frame { + if frame.asic == asic { + return Ok(frame.data); + } + } + } + } +} + +pub async fn detect_engine_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + row: u8, + col: u8, + wait: Duration, +) -> Result { + let engine_address = logical_engine_address(row, col); + let data = read_register_tdm_sync_stream( + reader, + writer, + asic, + engine_address, + ENGINE_REG_END_NONCE, + 4, + wait, + ) + .await?; + let end_nonce = u32::from_le_bytes(data.try_into().unwrap()); + Ok(end_nonce == DISCOVERED_ENGINE_END_NONCE) +} + +pub async fn discover_engine_map_stream( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + wait: Duration, +) -> Result { + set_tdm_enabled_stream(writer, tdm_prediv_raw, tdm_counter, true).await?; + + let result = async { + let mut present = Vec::new(); + let mut missing = Vec::new(); + + for (row, col) in physical_engine_coordinates() { + let coordinate = Bzm2EngineCoordinate::new(row, col); + if detect_engine_stream(reader, writer, asic, row, col, wait).await? { + present.push(coordinate); + } else { + missing.push(coordinate); + } + } + + Ok(Bzm2DiscoveredEngineMap { + asic, + present, + missing, + }) + } + .await; + + let disable_result = set_tdm_enabled_stream(writer, tdm_prediv_raw, tdm_counter, false).await; + match (result, disable_result) { + (Ok(map), Ok(())) => Ok(map), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } +} + +pub async fn read_dts_vs_frame_stream( + reader: &mut SerialReader, + generation: DtsVsGeneration, + asic: u8, + timeout: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + let mut parser = TdmFrameParser::new(generation); + let mut read_buf = [0u8; 256]; + + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(Bzm2UartError::DtsVsTimeout { asic }); + } + let remaining = deadline.saturating_duration_since(now); + let read = tokio::time::timeout(remaining, reader.read(&mut read_buf)) + .await + .map_err(|_| Bzm2UartError::DtsVsTimeout { asic })??; + if read == 0 { + return Err(Bzm2UartError::ShortResponse { + expected: 1, + actual: 0, + }); + } + for frame in parser.push(&read_buf[..read]) { + if let TdmFrame::DtsVs(frame) = frame { + let frame_asic = match frame { + TdmDtsVsFrame::Gen1(gen1) => gen1.asic, + TdmDtsVsFrame::Gen2(gen2) => gen2.asic, + }; + if frame_asic == asic { + return Ok(frame); + } + } + } + } +} + +async fn write_local_reg_u32_raw( + writer: &mut SerialWriter, + asic: u8, + offset: u8, + value: u32, +) -> Result<(), Bzm2UartError> { + writer + .write_all(&encode_write_register( + asic, + NOTCH_REG, + offset, + &value.to_le_bytes(), + )) + .await?; + writer.flush().await?; + Ok(()) +} + +async fn read_local_reg_u32_raw( + reader: &mut SerialReader, + writer: &mut SerialWriter, + asic: u8, + offset: u8, +) -> Result { + let request = encode_read_register(asic, NOTCH_REG, offset, 4); + writer.write_all(&request).await?; + writer.flush().await?; + + let mut response = [0u8; 6]; + reader.read_exact(&mut response).await?; + validate_response_header(asic, OPCODE_UART_READREG, &response)?; + Ok(u32::from_le_bytes(response[2..6].try_into().unwrap())) +} + +fn legacy_temperature_c_to_tune_code(temperature_c: i32) -> u32 { + let resolution_power = match THERMAL_SENSOR_RESOLUTION { + 10 => 1024.0_f32, + 8 => 256.0_f32, + _ => 4096.0_f32, + }; + (2048.0 / resolution_power + 4096.0 * (temperature_c as f32 + 293.8) / 631.8) as u32 +} + +fn legacy_voltage_mv_to_tune_code(voltage_mv: u32) -> u32 { + let resolution_power = match VOLTAGE_SENSOR_RESOLUTION { + 12 => 4096.0_f32, + 10 => 1024.0_f32, + 8 => 256.0_f32, + _ => 16384.0_f32, + }; + ((16384.0 / 6.0) * (2.5 * voltage_mv as f32 / 706.7 + 3.0 / resolution_power + 1.0)) as u32 +} + +fn encode_tdm_control(prediv_raw: u32, counter: u8, enable: bool) -> u32 { + (prediv_raw << 9) | ((counter as u32) << 1) | u32::from(enable) +} + +fn validate_response_header( + expected_asic: u8, + expected_opcode: u8, + response: &[u8], +) -> Result<(), Bzm2UartError> { + if response.len() < 2 { + return Err(Bzm2UartError::ShortResponse { + expected: 2, + actual: response.len(), + }); + } + + let actual_asic = response[0]; + let actual_opcode = response[1]; + if actual_asic != expected_asic || actual_opcode != expected_opcode { + return Err(Bzm2UartError::UnexpectedHeader { + expected_asic, + expected_opcode, + actual_asic, + actual_opcode, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::{Read, Write}; + use std::os::fd::AsRawFd; + + use nix::pty::openpty; + + use crate::transport::SerialStream; + + #[test] + fn response_header_validation_accepts_matching_unicast_response() { + validate_response_header(0x12, OPCODE_UART_READREG, &[0x12, OPCODE_UART_READREG]).unwrap(); + } + + #[test] + fn response_header_validation_rejects_mismatched_response() { + let err = validate_response_header(0x12, OPCODE_UART_NOOP, &[0x13, OPCODE_UART_LOOPBACK]) + .unwrap_err(); + assert!(matches!( + err, + Bzm2UartError::UnexpectedHeader { + expected_asic: 0x12, + expected_opcode: OPCODE_UART_NOOP, + actual_asic: 0x13, + actual_opcode: OPCODE_UART_LOOPBACK, + } + )); + } + + #[test] + fn legacy_temperature_query_threshold_matches_legacy_formula_family() { + assert_eq!(legacy_temperature_c_to_tune_code(115), 2650); + } + + #[test] + fn legacy_voltage_query_threshold_matches_legacy_formula_family() { + assert_eq!(legacy_voltage_mv_to_tune_code(500), 7561); + } + + #[test] + fn default_asic_id_matches_legacy_value() { + assert_eq!(DEFAULT_ASIC_ID, 0xfa); + } + + #[test] + fn default_noop_probe_timeout_is_bounded() { + assert_eq!(DEFAULT_NOOP_PROBE_TIMEOUT, Duration::from_millis(100)); + } + + #[test] + fn discovered_engine_map_counts_entries() { + let map = Bzm2DiscoveredEngineMap { + asic: 2, + present: vec![ + Bzm2EngineCoordinate::new(0, 0), + Bzm2EngineCoordinate::new(1, 0), + ], + missing: vec![Bzm2EngineCoordinate::new(0, 4)], + }; + + assert_eq!(map.present_count(), 2); + assert_eq!(map.missing_count(), 1); + } + + #[tokio::test] + async fn read_register_tdm_sync_decodes_engine_response() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let engine_address = logical_engine_address(3, 4); + + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let mut request = [0u8; 8]; + file.read_exact(&mut request).unwrap(); + assert_eq!( + request.to_vec(), + encode_read_register(2, engine_address, ENGINE_REG_END_NONCE, 4) + ); + file.write_all(&[2, OPCODE_UART_READREG, 0xfe, 0xff, 0xff, 0xff]) + .unwrap(); + file.flush().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + }); + + let stream = SerialStream::new(&serial_path, 5_000_000).unwrap(); + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let data = uart + .read_register_tdm_sync( + 2, + engine_address, + ENGINE_REG_END_NONCE, + 4, + Duration::from_millis(100), + ) + .await + .unwrap(); + assert_eq!(data, vec![0xfe, 0xff, 0xff, 0xff]); + + emulator.join().unwrap(); + } + + #[tokio::test] + async fn discover_engine_map_scans_physical_coordinates() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let present = std::collections::BTreeSet::from([(0u8, 0u8), (19u8, 10u8)]); + let prediv = 0x0f; + let counter = 16; + + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let expected_enable = encode_write_register( + BROADCAST_ASIC, + NOTCH_REG, + LOCAL_REG_UART_TDM_CTL, + &encode_tdm_control(prediv, counter, true).to_le_bytes(), + ); + let mut enable_request = vec![0u8; expected_enable.len()]; + file.read_exact(&mut enable_request).unwrap(); + assert_eq!(enable_request, expected_enable); + + for (row, col) in physical_engine_coordinates() { + let mut request = [0u8; 8]; + file.read_exact(&mut request).unwrap(); + assert_eq!( + request.to_vec(), + encode_read_register( + 1, + logical_engine_address(row, col), + ENGINE_REG_END_NONCE, + 4 + ) + ); + let value = if present.contains(&(row, col)) { + DISCOVERED_ENGINE_END_NONCE + } else { + 0 + }; + let mut response = vec![1, OPCODE_UART_READREG]; + response.extend_from_slice(&value.to_le_bytes()); + file.write_all(&response).unwrap(); + file.flush().unwrap(); + } + + let expected_disable = encode_write_register( + BROADCAST_ASIC, + NOTCH_REG, + LOCAL_REG_UART_TDM_CTL, + &encode_tdm_control(prediv, counter, false).to_le_bytes(), + ); + let mut disable_request = vec![0u8; expected_disable.len()]; + file.read_exact(&mut disable_request).unwrap(); + assert_eq!(disable_request, expected_disable); + std::thread::sleep(Duration::from_millis(20)); + }); + + let stream = SerialStream::new(&serial_path, 5_000_000).unwrap(); + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let discovery = uart + .discover_engine_map(1, prediv, counter, Duration::from_millis(100)) + .await + .unwrap(); + + assert_eq!(discovery.present_count(), 2); + assert_eq!( + discovery.missing_count(), + physical_engine_coordinates().len() - 2 + ); + assert_eq!( + discovery.present, + vec![ + Bzm2EngineCoordinate::new(0, 0), + Bzm2EngineCoordinate::new(19, 10) + ] + ); + + emulator.join().unwrap(); + } +} diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index 26dd1c75..c901cc1a 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -74,6 +74,29 @@ pub struct HashThreadStatus { pub is_active: bool, } +/// Temperature reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadTemperatureReading { + pub name: String, + pub temperature_c: Option, +} + +/// Voltage/current/power reading reported by a hash thread. +#[derive(Debug, Clone, PartialEq)] +pub struct HashThreadPowerReading { + pub name: String, + pub voltage_v: Option, + pub current_a: Option, + pub power_w: Option, +} + +/// Telemetry update reported by a hash thread. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct HashThreadTelemetryUpdate { + pub temperatures: Vec, + pub powers: Vec, +} + /// Events emitted by HashThreads back to the scheduler. /// /// When a thread shuts down (USB unplug, fault, user request, etc.), it closes @@ -99,6 +122,9 @@ pub enum HashThreadEvent { /// Periodic status update StatusUpdate(HashThreadStatus), + + /// Additional telemetry update + TelemetryUpdate(HashThreadTelemetryUpdate), } /// Error types for HashThread operations. @@ -116,6 +142,12 @@ pub enum HashThreadError { #[error("Preemption failed: {0}")] PreemptionFailed(String), + #[error("Telemetry query failed: {0}")] + TelemetryQueryFailed(String), + + #[error("Diagnostics failed: {0}")] + DiagnosticsFailed(String), + #[error("Shutdown timeout")] ShutdownTimeout, diff --git a/mujina-miner/src/asic/mod.rs b/mujina-miner/src/asic/mod.rs index 062ee8fc..d5a74cb3 100644 --- a/mujina-miner/src/asic/mod.rs +++ b/mujina-miner/src/asic/mod.rs @@ -1,4 +1,5 @@ pub mod bm13xx; +pub mod bzm2; pub mod hash_thread; use async_trait::async_trait; diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index 750011c1..10178d6a 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -13,6 +13,7 @@ use crate::{ transport::{ TransportEvent, UsbDeviceInfo, cpu::TransportEvent as CpuTransportEvent, usb::TransportEvent as UsbTransportEvent, + virtual_device::TransportEvent as VirtualTransportEvent, }, }; use std::collections::HashMap; @@ -80,6 +81,9 @@ impl Backplane { TransportEvent::Cpu(cpu_event) => { self.handle_cpu_event(cpu_event).await?; } + TransportEvent::Virtual(virtual_event) => { + self.handle_virtual_event(virtual_event).await?; + } } } @@ -328,4 +332,102 @@ impl Backplane { Ok(()) } + + /// Handle generic virtual device transport events. + async fn handle_virtual_event(&mut self, event: VirtualTransportEvent) -> Result<()> { + match event { + VirtualTransportEvent::VirtualDeviceConnected(device_info) => { + let Some(descriptor) = self.virtual_registry.find(&device_info.device_type) else { + error!( + device_type = %device_info.device_type, + "No virtual board descriptor found" + ); + return Ok(()); + }; + + info!( + board = descriptor.name, + device_type = %device_info.device_type, + device_id = %device_info.device_id, + "Virtual board connected." + ); + + let (mut board, registration) = match (descriptor.create_fn)().await { + Ok(result) => result, + Err(e) => { + error!( + board = descriptor.name, + error = %e, + "Failed to create virtual board" + ); + return Ok(()); + } + }; + + let board_info = board.board_info(); + let board_id = device_info.device_id.clone(); + + if let Err(e) = self.board_reg_tx.send(registration).await { + error!( + board = %board_info.model, + error = %e, + "Failed to register board with API server" + ); + } + + match board.create_hash_threads().await { + Ok(threads) => { + let thread_count = threads.len(); + self.boards.insert(board_id.clone(), board); + + for thread in threads { + if let Err(e) = self.scheduler_tx.send(thread).await { + error!( + board = %board_info.model, + error = %e, + "Failed to send thread to scheduler" + ); + break; + } + } + + info!( + board = %board_info.model, + device_id = %board_id, + threads = thread_count, + "Virtual board started." + ); + } + Err(e) => { + error!( + board = %board_info.model, + device_id = %board_id, + error = %e, + "Virtual board failed to start." + ); + } + } + } + VirtualTransportEvent::VirtualDeviceDisconnected { device_id } => { + if let Some(mut board) = self.boards.remove(&device_id) { + let model = board.board_info().model; + debug!(board = %model, id = %device_id, "Shutting down virtual board"); + + match board.shutdown().await { + Ok(()) => { + info!(board = %model, id = %device_id, "Virtual board disconnected") + } + Err(e) => error!( + board = %model, + id = %device_id, + error = %e, + "Failed to shutdown virtual board" + ), + } + } + } + } + + Ok(()) + } } diff --git a/mujina-miner/src/bin/bzm2-debug.rs b/mujina-miner/src/bin/bzm2-debug.rs new file mode 100644 index 00000000..1d20ca25 --- /dev/null +++ b/mujina-miner/src/bin/bzm2-debug.rs @@ -0,0 +1,1391 @@ +use std::collections::BTreeMap; +use std::env; +use std::ops::RangeInclusive; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use mujina_miner::asic::bzm2::protocol::{ + DtsVsGeneration, ENGINE_REG_TARGET, ENGINE_REG_TIMESTAMP_COUNT, ENGINE_REG_ZEROS_TO_FIND, + TdmFrame, TdmFrameParser, default_engine_coordinates, default_excluded_engines, + encode_read_register, encode_read_result_command, logical_engine_address, +}; +use mujina_miner::asic::bzm2::{ + BROADCAST_GROUP_ASIC, Bzm2ClockController, Bzm2Dll, Bzm2DtsVsConfig, Bzm2Pll, + Bzm2UartController, DEFAULT_ASIC_ID, DEFAULT_DTS_VS_QUERY_TIMEOUT, NOTCH_REG, +}; +use mujina_miner::transport::{SerialReader, SerialStream, SerialWriter}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Instant, timeout}; + +const DEFAULT_BAUD: u32 = 5_000_000; +const DEFAULT_WATCH_POLL_MS: u64 = 100; +const DEFAULT_BROADCAST_READ_TIMEOUT_MS: u64 = 2_000; +const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; +const LOCAL_REG_UART_TDM_CTL: u8 = 0x07; +const MAX_EFFBST_SUBJOBS: u8 = 4; + +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec = env::args().collect(); + if args.len() < 2 { + print_usage(); + std::process::exit(1); + } + + match args[1].as_str() { + "uart-read" => cmd_uart_read(&args[2..]).await, + "uart-write" => cmd_uart_write(&args[2..]).await, + "uart-multicast-write" => cmd_uart_multicast_write(&args[2..]).await, + "uart-noop" => cmd_uart_noop(&args[2..]).await, + "uart-loopback" => cmd_uart_loopback(&args[2..]).await, + "uart-read-result" => cmd_uart_read_result(&args[2..]).await, + "dts-vs-query" => cmd_dts_vs_query(&args[2..]).await, + "dts-vs-scan" => cmd_dts_vs_scan(&args[2..]).await, + "enumerate-chain" => cmd_enumerate_chain(&args[2..]).await, + "noop-scan" => cmd_noop_scan(&args[2..]).await, + "loopback-scan" => cmd_loopback_scan(&args[2..]).await, + "tdm-enable" => cmd_tdm_enable(&args[2..]).await, + "tdm-disable" => cmd_tdm_disable(&args[2..]).await, + "tdm-watch" => cmd_tdm_watch(&args[2..]).await, + "tdm-broadcast-read-watch" => cmd_tdm_broadcast_read_watch(&args[2..]).await, + "engine-probe" => cmd_engine_probe(&args[2..]).await, + "discover-engine-map" => cmd_discover_engine_map(&args[2..]).await, + "engine-target-all" => cmd_engine_target_all(&args[2..]).await, + "engine-timestamp-all" => cmd_engine_timestamp_all(&args[2..]).await, + "engine-zeros-all" => cmd_engine_zeros_all(&args[2..]).await, + "job-grid" => cmd_job_grid(&args[2..]).await, + "job-grid-watch" => cmd_job_grid_watch(&args[2..]).await, + "job-grid-2phase-watch" => cmd_job_grid_2phase_watch(&args[2..]).await, + "clock-report" => cmd_clock_report(&args[2..]).await, + "pll-set" => cmd_pll_set(&args[2..]).await, + "dll-set" => cmd_dll_set(&args[2..]).await, + "pll-broadcast-lock-check" => cmd_pll_broadcast_lock_check(&args[2..]).await, + "dll-broadcast-lock-check" => cmd_dll_broadcast_lock_check(&args[2..]).await, + other => bail!("unknown command: {other}"), + } +} + +fn print_usage() { + eprintln!("Usage: mujina-bzm2-debug [args]"); + eprintln!(); + eprintln!("Direct UART developer commands:"); + eprintln!(" uart-read [baud]"); + eprintln!(" uart-write [baud]"); + eprintln!( + " uart-multicast-write [baud]" + ); + eprintln!(" uart-noop [baud]"); + eprintln!(" uart-loopback [baud]"); + eprintln!(" uart-read-result [baud]"); + eprintln!(" dts-vs-query [timeout-ms] [baud]"); + eprintln!(" dts-vs-scan [timeout-ms] [baud]"); + eprintln!(" enumerate-chain [start-id] [baud]"); + eprintln!(" noop-scan [baud]"); + eprintln!(" loopback-scan [baud]"); + eprintln!(); + eprintln!("TDM control and observation:"); + eprintln!(" tdm-enable [baud]"); + eprintln!(" tdm-disable [baud]"); + eprintln!(" tdm-watch [baud]"); + eprintln!( + " tdm-broadcast-read-watch [baud]" + ); + eprintln!( + " engine-probe [timeout-ms] [baud]" + ); + eprintln!( + " discover-engine-map [timeout-ms] [baud]" + ); + eprintln!(); + eprintln!("Engine-wide validation helpers:"); + eprintln!(" engine-target-all [baud]"); + eprintln!(" engine-timestamp-all [baud]"); + eprintln!(" engine-zeros-all [baud]"); + eprintln!(" job-grid [baud]"); + eprintln!( + " job-grid-watch [baud]" + ); + eprintln!( + " job-grid-2phase-watch [baud]" + ); + eprintln!(); + eprintln!("Clock diagnostics:"); + eprintln!(" clock-report [baud]"); + eprintln!(" pll-set [baud]"); + eprintln!(" dll-set [baud]"); + eprintln!( + " pll-broadcast-lock-check [baud]" + ); + eprintln!( + " dll-broadcast-lock-check [baud]" + ); + eprintln!(); + eprintln!("Addressing examples:"); + eprintln!(" unicast : uart-write /dev/ttyUSB0 2 notch 0x12 01000000"); + eprintln!(" broadcast : uart-write /dev/ttyUSB0 broadcast notch 0x12 01000000"); + eprintln!(" multicast : uart-multicast-write /dev/ttyUSB0 2 7 0x49 3c"); + eprintln!(" enumerate : enumerate-chain /dev/ttyUSB0 32 0"); + eprintln!(" scan : noop-scan /dev/ttyUSB0 0 15"); + eprintln!(" TDM watch : tdm-watch /dev/ttyUSB0 gen2 5"); + eprintln!(" grid job : job-grid-watch /dev/ttyUSB0 2 0 1700000000 60 5 gen2"); +} + +fn parse_baud(raw: Option<&String>) -> Result { + match raw { + Some(raw) => Ok(parse_u32(raw)?), + None => Ok(DEFAULT_BAUD), + } +} + +fn parse_watch_duration(raw: &str) -> Result { + let seconds = parse_f32(raw)?; + if seconds <= 0.0 { + bail!("watch duration must be greater than zero"); + } + Ok(Duration::from_secs_f32(seconds)) +} + +fn parse_timeout_duration(raw: Option<&String>) -> Result { + match raw { + Some(raw) => { + let millis = parse_u32(raw)?; + if millis == 0 { + bail!("timeout-ms must be greater than zero"); + } + Ok(Duration::from_millis(millis as u64)) + } + None => Ok(DEFAULT_DTS_VS_QUERY_TIMEOUT), + } +} + +fn parse_engine_discovery_timeout(raw: Option<&String>) -> Result { + match raw { + Some(raw) => { + let millis = parse_u32(raw)?; + if millis == 0 { + bail!("timeout-ms must be greater than zero"); + } + Ok(Duration::from_millis(millis as u64)) + } + None => Ok(Duration::from_millis(DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS)), + } +} + +fn parse_dts_generation(raw: &str) -> Result { + DtsVsGeneration::from_env_value(raw) + .ok_or_else(|| anyhow::anyhow!("invalid DTS/VS generation: {raw}")) +} + +fn parse_u8(raw: &str) -> Result { + Ok(parse_u32(raw)? as u8) +} + +fn parse_u16(raw: &str) -> Result { + Ok(parse_u32(raw)? as u16) +} + +fn parse_u32(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + Ok(u32::from_str_radix(stripped, 16)?) + } else { + Ok(raw.parse()?) + } +} + +fn parse_f32(raw: &str) -> Result { + Ok(raw.parse()?) +} + +fn parse_hex_bytes(raw: &str) -> Result> { + let sanitized: String = raw + .chars() + .filter(|c| !c.is_ascii_whitespace() && *c != '_' && *c != ':') + .collect(); + if sanitized.len() % 2 != 0 { + bail!("hex payload must have an even number of digits"); + } + Ok(hex::decode(sanitized)?) +} + +fn parse_asic_or_broadcast(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("broadcast") { + Ok(BROADCAST_GROUP_ASIC) + } else { + parse_u8(raw) + } +} + +fn parse_asic_range(start: &str, end: &str) -> Result> { + let start = parse_u8(start)?; + let end = parse_u8(end)?; + if start > end { + bail!("asic-start must be less than or equal to asic-end"); + } + Ok(start..=end) +} + +fn parse_engine_address(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("notch") { + Ok(NOTCH_REG) + } else { + parse_u16(raw) + } +} + +fn parse_pll(raw: &str) -> Result { + match raw.to_ascii_lowercase().as_str() { + "pll0" | "0" => Ok(Bzm2Pll::Pll0), + "pll1" | "1" => Ok(Bzm2Pll::Pll1), + _ => bail!("invalid PLL selector: {raw}"), + } +} + +fn parse_dll(raw: &str) -> Result { + match raw.to_ascii_lowercase().as_str() { + "dll0" | "0" => Ok(Bzm2Dll::Dll0), + "dll1" | "1" => Ok(Bzm2Dll::Dll1), + _ => bail!("invalid DLL selector: {raw}"), + } +} + +fn parse_zeros_to_find(raw: &str) -> Result { + let zeros = parse_u8(raw)?; + if !(32..=64).contains(&zeros) { + bail!("zeros-to-find must be in the inclusive range 32..=64"); + } + Ok(zeros) +} + +fn open_uart(serial: &str, baud: u32) -> Result { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok(Bzm2UartController::new(reader, writer)) +} + +fn open_clock(serial: &str, baud: u32) -> Result { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok(Bzm2ClockController::new(reader, writer)) +} + +fn open_raw(serial: &str, baud: u32) -> Result<(SerialReader, SerialWriter)> { + let stream = SerialStream::new(serial, baud) + .with_context(|| format!("failed to open serial port {serial} at {baud} baud"))?; + let (reader, writer, _) = stream.split(); + Ok((reader, writer)) +} + +async fn cmd_uart_read(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!("usage: uart-read [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_u8(&args[1])?; + let engine = parse_engine_address(&args[2])?; + let offset = parse_u8(&args[3])?; + let count = parse_u8(&args[4])?; + let data = uart.read_register(asic, engine, offset, count).await?; + println!("{}", hex::encode(data)); + Ok(()) +} + +async fn cmd_uart_write(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: uart-write [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let engine = parse_engine_address(&args[2])?; + let offset = parse_u8(&args[3])?; + let value = parse_hex_bytes(&args[4])?; + uart.write_register(asic, engine, offset, &value).await?; + println!("ok"); + Ok(()) +} + +async fn cmd_uart_multicast_write(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: uart-multicast-write [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let group = parse_u16(&args[2])?; + let offset = parse_u8(&args[3])?; + let value = parse_hex_bytes(&args[4])?; + uart.multicast_write_register(asic, group, offset, &value) + .await?; + println!("ok"); + Ok(()) +} + +async fn cmd_uart_noop(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 3 { + bail!("usage: uart-noop [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(2))?)?; + let asic = parse_u8(&args[1])?; + let value = uart.noop(asic).await?; + println!( + "ascii={}{}{} hex={}", + value[0] as char, + value[1] as char, + value[2] as char, + hex::encode(value) + ); + Ok(()) +} + +async fn cmd_uart_loopback(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: uart-loopback [baud]"); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_u8(&args[1])?; + let payload = parse_hex_bytes(&args[2])?; + let echoed = uart.loopback(asic, &payload).await?; + println!("{}", hex::encode(echoed)); + Ok(()) +} + +async fn cmd_uart_read_result(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: uart-read-result [baud]"); + } + + let asic = parse_u8(&args[1])?; + let generation = parse_dts_generation(&args[2])?; + let baud = parse_baud(args.get(3))?; + let (mut reader, mut writer) = open_raw(&args[0], baud)?; + writer + .write_all(&encode_read_result_command(asic)) + .await + .context("failed to send read-result command")?; + writer + .flush() + .await + .context("failed to flush serial stream")?; + + let mut parser = TdmFrameParser::new(generation); + let frames = read_tdm_frames_once( + &mut reader, + &mut parser, + Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS), + ) + .await?; + + if frames.is_empty() { + bail!("no TDM frame returned for ASIC {asic}"); + } + + for frame in frames { + print_tdm_frame(&frame); + } + + Ok(()) +} + +async fn cmd_dts_vs_query(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 5 { + bail!("usage: dts-vs-query [timeout-ms] [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; + let asic = parse_u8(&args[1])?; + let generation = parse_dts_generation(&args[2])?; + let timeout = parse_timeout_duration(args.get(3))?; + let frame = uart + .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) + .await?; + print_dts_vs_query_frame(&frame); + Ok(()) +} + +async fn cmd_dts_vs_scan(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 6 { + bail!("usage: dts-vs-scan [timeout-ms] [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let generation = parse_dts_generation(&args[3])?; + let timeout = parse_timeout_duration(args.get(4))?; + + for asic in asics { + let frame = uart + .query_dts_vs(asic, generation, Bzm2DtsVsConfig::default(), timeout) + .await?; + print_dts_vs_query_frame(&frame); + } + + Ok(()) +} + +async fn cmd_enumerate_chain(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 4 { + bail!("usage: enumerate-chain [start-id] [baud]"); + } + + let max_asics = parse_u8(&args[1])?; + if max_asics == 0 { + bail!("max-asics must be greater than zero"); + } + + let (start_id, baud_arg) = match args.len() { + 2 => (0, None), + 3 => (parse_u8(&args[2])?, None), + 4 => (parse_u8(&args[2])?, args.get(3)), + _ => unreachable!(), + }; + let mut uart = open_uart(&args[0], parse_baud(baud_arg)?)?; + + let assigned = uart.enumerate_chain(max_asics, start_id).await?; + println!( + "enumerated {} ASIC(s) from default id 0x{DEFAULT_ASIC_ID:02x}", + assigned.len() + ); + for asic in &assigned { + println!(" assigned asic id {}", asic); + } + if assigned.is_empty() { + println!(" no ASIC responded on the default id"); + } + + Ok(()) +} + +async fn cmd_noop_scan(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: noop-scan [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let mut found = Vec::new(); + + for asic in asics { + match uart.noop(asic).await { + Ok(value) => { + let ascii = String::from_utf8_lossy(&value); + println!("asic {asic}: ascii={ascii} hex={}", hex::encode(value)); + found.push(asic); + } + Err(err) => { + println!("asic {asic}: no response ({err})"); + } + } + } + + println!("responsive_asics={}", found.len()); + if !found.is_empty() { + println!("asic_ids={}", format_asic_ids(&found)); + } + + Ok(()) +} + +async fn cmd_loopback_scan(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 5 { + bail!("usage: loopback-scan [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(4))?)?; + let asics = parse_asic_range(&args[1], &args[2])?; + let payload_len = parse_u8(&args[3])? as usize; + if payload_len == 0 { + bail!("payload-len must be greater than zero"); + } + + for asic in asics { + let payload = synthetic_loopback_payload(asic, payload_len); + let echoed = uart.loopback(asic, &payload).await?; + let matched = payload == echoed; + println!( + "asic {asic}: matched={} payload={} echoed={}", + matched, + hex::encode(&payload), + hex::encode(&echoed) + ); + if !matched { + bail!("loopback mismatch on ASIC {asic}"); + } + } + + Ok(()) +} + +async fn cmd_tdm_enable(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-enable [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let prediv = parse_u32(&args[1])?; + let counter = parse_u8(&args[2])?; + let control = encode_tdm_control(prediv, counter, true); + uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) + .await?; + println!( + "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=true" + ); + Ok(()) +} + +async fn cmd_tdm_disable(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-disable [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let prediv = parse_u32(&args[1])?; + let counter = parse_u8(&args[2])?; + let control = encode_tdm_control(prediv, counter, false); + uart.write_local_reg_u32(BROADCAST_GROUP_ASIC, LOCAL_REG_UART_TDM_CTL, control) + .await?; + println!( + "broadcast local_reg=0x{LOCAL_REG_UART_TDM_CTL:02x} control={control:#010x} enabled=false" + ); + Ok(()) +} + +async fn cmd_tdm_watch(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: tdm-watch [baud]"); + } + + let generation = parse_dts_generation(&args[1])?; + let duration = parse_watch_duration(&args[2])?; + let baud = parse_baud(args.get(3))?; + let (mut reader, _) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + +async fn cmd_tdm_broadcast_read_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: tdm-broadcast-read-watch [baud]" + ); + } + + let generation = parse_dts_generation(&args[1])?; + let asics = parse_asic_range(&args[2], &args[3])?; + let engine = parse_engine_address(&args[4])?; + let offset = parse_u8(&args[5])?; + let count = parse_u8(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, mut writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let expected = asics.clone().count(); + + for asic in asics.clone() { + parser.expect_read_register_bytes(asic, count as usize); + } + + writer + .write_all(&encode_read_register( + BROADCAST_GROUP_ASIC, + engine, + offset, + count, + )) + .await + .context("failed to send broadcast read-register command")?; + writer + .flush() + .await + .context("failed to flush serial stream")?; + + let mut received = BTreeMap::new(); + let deadline = Instant::now() + Duration::from_millis(DEFAULT_BROADCAST_READ_TIMEOUT_MS); + while Instant::now() < deadline && received.len() < expected { + let frames = read_tdm_frames_once( + &mut reader, + &mut parser, + Duration::from_millis(DEFAULT_WATCH_POLL_MS), + ) + .await?; + for frame in frames { + print_tdm_frame(&frame); + if let TdmFrame::Register(register) = frame { + received.insert(register.asic, register.data); + } + } + } + + println!( + "broadcast-read summary: received={} expected={} missing={}", + received.len(), + expected, + expected.saturating_sub(received.len()) + ); + + if received.len() != expected { + let mut missing = Vec::new(); + for asic in asics { + if !received.contains_key(&asic) { + missing.push(asic); + } + } + if !missing.is_empty() { + println!("missing_asics={}", format_asic_ids(&missing)); + } + } + + Ok(()) +} + +async fn cmd_engine_probe(args: &[String]) -> Result<()> { + if args.len() < 6 || args.len() > 8 { + bail!( + "usage: engine-probe [timeout-ms] [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(7))?)?; + let asic = parse_u8(&args[1])?; + let row = parse_u8(&args[2])?; + let col = parse_u8(&args[3])?; + let prediv = parse_u32(&args[4])?; + let counter = parse_u8(&args[5])?; + let timeout = parse_engine_discovery_timeout(args.get(6))?; + + uart.enable_tdm(prediv, counter).await?; + let result = uart.detect_engine(asic, row, col, timeout).await; + let disable_result = uart.disable_tdm(prediv, counter).await; + let present = result?; + disable_result?; + + println!( + "asic={asic} row={row} col={col} engine=0x{:03x} present={present}", + logical_engine_address(row, col) + ); + Ok(()) +} + +async fn cmd_discover_engine_map(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 6 { + bail!( + "usage: discover-engine-map [timeout-ms] [baud]" + ); + } + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_u8(&args[1])?; + let prediv = parse_u32(&args[2])?; + let counter = parse_u8(&args[3])?; + let timeout = parse_engine_discovery_timeout(args.get(4))?; + + let discovery = uart + .discover_engine_map(asic, prediv, counter, timeout) + .await?; + + let legacy_missing = default_excluded_engines(); + let discovered_missing = discovery + .missing + .iter() + .map(|coord| (coord.row, coord.col)) + .collect::>(); + println!( + "asic={} present={} missing={} legacy_default_holes_match={}", + asic, + discovery.present_count(), + discovery.missing_count(), + discovered_missing.len() == legacy_missing.len() + && discovered_missing + .iter() + .all(|coord| legacy_missing.contains(coord)) + ); + if !discovery.missing.is_empty() { + println!("missing: {}", format_engine_coordinates(&discovery.missing)); + } + Ok(()) +} + +async fn cmd_engine_target_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-target-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let target = parse_u32(&args[2])?; + write_engine_reg_all_u32(&mut uart, asic, ENGINE_REG_TARGET, target).await?; + println!("programmed engine target across all rows: asic={asic:#04x} target={target:#010x}"); + Ok(()) +} + +async fn cmd_engine_timestamp_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-timestamp-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let value = parse_u8(&args[2])?; + write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_TIMESTAMP_COUNT, value).await?; + println!( + "programmed ENGINE_REG_TIMESTAMP_COUNT across all rows: asic={asic:#04x} value={value}" + ); + Ok(()) +} + +async fn cmd_engine_zeros_all(args: &[String]) -> Result<()> { + if args.len() < 3 || args.len() > 4 { + bail!("usage: engine-zeros-all [baud]"); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(3))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let zeros = parse_zeros_to_find(&args[2])?; + let register_value = zeros - 32; + write_engine_reg_all_u8(&mut uart, asic, ENGINE_REG_ZEROS_TO_FIND, register_value).await?; + println!( + "programmed ENGINE_REG_ZEROS_TO_FIND across all rows: asic={asic:#04x} zeros_to_find={zeros} register_value={register_value}" + ); + Ok(()) +} + +async fn cmd_job_grid(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: job-grid [baud]" + ); + } + + let mut uart = open_uart(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + println!( + "dispatched grid job set: asic={asic:#04x} engines={} seq_base={} ntime={ntime:#010x} timestamp_count={timestamp_count}", + default_engine_coordinates().len(), + sequence_base + ); + Ok(()) +} + +async fn cmd_job_grid_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: job-grid-watch [baud]" + ); + } + + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + let duration = parse_watch_duration(&args[5])?; + let generation = parse_dts_generation(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let mut uart = Bzm2UartController::new(reader.clone(), writer); + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + +async fn cmd_job_grid_2phase_watch(args: &[String]) -> Result<()> { + if args.len() < 7 || args.len() > 8 { + bail!( + "usage: job-grid-2phase-watch [baud]" + ); + } + + let asic = parse_asic_or_broadcast(&args[1])?; + let sequence_base = parse_u8(&args[2])?; + let ntime = parse_u32(&args[3])?; + let timestamp_count = parse_u8(&args[4])?; + let duration = parse_watch_duration(&args[5])?; + let generation = parse_dts_generation(&args[6])?; + let baud = parse_baud(args.get(7))?; + let (mut reader, writer) = open_raw(&args[0], baud)?; + let mut parser = TdmFrameParser::new(generation); + let mut uart = Bzm2UartController::new(reader.clone(), writer); + + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base, + ntime, + timestamp_count, + false, + ) + .await?; + dispatch_grid_jobs( + &mut uart, + asic, + sequence_base.wrapping_add(MAX_EFFBST_SUBJOBS), + ntime.wrapping_add(1), + timestamp_count, + true, + ) + .await?; + + let stats = watch_tdm_frames(&mut reader, &mut parser, duration).await?; + println!( + "summary: result={} register={} noop={} dts_vs={}", + stats.result, stats.register, stats.noop, stats.dts_vs + ); + Ok(()) +} + +async fn cmd_clock_report(args: &[String]) -> Result<()> { + if args.len() < 2 || args.len() > 3 { + bail!("usage: clock-report [baud]"); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(2))?)?; + let asic = parse_u8(&args[1])?; + let report = clock.debug_report(asic).await?; + println!("ASIC {}", report.asic); + println!( + " PLL0: enabled={} locked={} enable={:#010x} misc={:#010x}", + report.pll0.enabled, + report.pll0.locked, + report.pll0.enable_register, + report.pll0.misc_register + ); + println!( + " PLL1: enabled={} locked={} enable={:#010x} misc={:#010x}", + report.pll1.enabled, + report.pll1.locked, + report.pll1.enable_register, + report.pll1.misc_register + ); + println!( + " DLL0: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", + report.dll0.locked, + report.dll0.freeze_valid, + report.dll0.coarsecon, + report.dll0.fincon, + report.dll0.fincon_valid, + report.dll0.control2, + report.dll0.control5 + ); + println!( + " DLL1: locked={} freeze_valid={} coarsecon={} fincon={:#04x} fincon_valid={} control2={:#04x} control5={:#04x}", + report.dll1.locked, + report.dll1.freeze_valid, + report.dll1.coarsecon, + report.dll1.fincon, + report.dll1.fincon_valid, + report.dll1.control2, + report.dll1.control5 + ); + Ok(()) +} + +async fn cmd_pll_set(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: pll-set [baud]" + ); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let pll = parse_pll(&args[2])?; + let freq = parse_f32(&args[3])?; + let post1 = parse_u8(&args[4])?; + + if asic == BROADCAST_GROUP_ASIC { + let config = clock.set_pll_frequency(asic, pll, freq, post1).await?; + clock.enable_pll(asic, pll).await?; + println!( + "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", + pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider + ); + } else { + let (config, status) = clock + .configure_and_lock_pll(asic, pll, freq, post1, Duration::from_secs(3)) + .await?; + println!( + "asic {} {:?}: freq={}MHz fbdiv={} postdiv={:#x} locked={} enable={:#010x}", + asic, + pll, + config.frequency_mhz, + config.feedback_divider, + config.packed_post_divider, + status.locked, + status.enable_register + ); + } + Ok(()) +} + +async fn cmd_dll_set(args: &[String]) -> Result<()> { + if args.len() < 4 || args.len() > 5 { + bail!("usage: dll-set [baud]"); + } + let mut clock = open_clock(&args[0], parse_baud(args.get(4))?)?; + let asic = parse_asic_or_broadcast(&args[1])?; + let dll = parse_dll(&args[2])?; + let duty = parse_u8(&args[3])?; + + if asic == BROADCAST_GROUP_ASIC { + let config = clock.set_dll_duty_cycle(asic, dll, duty).await?; + clock.enable_dll(asic, dll).await?; + println!( + "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", + dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk + ); + } else { + let (config, status) = clock + .configure_and_lock_dll(asic, dll, duty, Duration::from_secs(2)) + .await?; + println!( + "asic {} {:?}: duty={} locked={} coarsecon={} fincon={:#04x} fincon_valid={}", + asic, + dll, + config.duty_cycle, + status.locked, + status.coarsecon, + status.fincon, + status.fincon_valid + ); + } + Ok(()) +} + +async fn cmd_pll_broadcast_lock_check(args: &[String]) -> Result<()> { + if args.len() < 6 || args.len() > 7 { + bail!( + "usage: pll-broadcast-lock-check [baud]" + ); + } + + let mut clock = open_clock(&args[0], parse_baud(args.get(6))?)?; + let pll = parse_pll(&args[1])?; + let freq = parse_f32(&args[2])?; + let post1 = parse_u8(&args[3])?; + let asics = parse_asic_range(&args[4], &args[5])?; + + let config = clock + .set_pll_frequency(BROADCAST_GROUP_ASIC, pll, freq, post1) + .await?; + clock.enable_pll(BROADCAST_GROUP_ASIC, pll).await?; + + println!( + "broadcast {:?}: freq={}MHz fbdiv={} postdiv={:#x}", + pll, config.frequency_mhz, config.feedback_divider, config.packed_post_divider + ); + + for asic in asics { + let status = clock + .wait_for_pll_lock( + asic, + pll, + Duration::from_secs(3), + Duration::from_millis(100), + ) + .await?; + println!( + "asic {} {:?}: locked={} enable={:#010x} misc={:#010x}", + asic, pll, status.locked, status.enable_register, status.misc_register + ); + } + + Ok(()) +} + +async fn cmd_dll_broadcast_lock_check(args: &[String]) -> Result<()> { + if args.len() < 5 || args.len() > 6 { + bail!( + "usage: dll-broadcast-lock-check [baud]" + ); + } + + let mut clock = open_clock(&args[0], parse_baud(args.get(5))?)?; + let dll = parse_dll(&args[1])?; + let duty = parse_u8(&args[2])?; + let asics = parse_asic_range(&args[3], &args[4])?; + + let config = clock + .set_dll_duty_cycle(BROADCAST_GROUP_ASIC, dll, duty) + .await?; + clock.enable_dll(BROADCAST_GROUP_ASIC, dll).await?; + + println!( + "broadcast {:?}: duty={} nde_dll={:#x} nde_clk={:#x} npi_clk={:#x}", + dll, config.duty_cycle, config.nde_dll, config.nde_clk, config.npi_clk + ); + + for asic in asics { + clock + .wait_for_dll_lock(asic, dll, Duration::from_secs(2), Duration::from_millis(10)) + .await?; + let status = clock.ensure_dll_fincon_valid(asic, dll).await?; + + println!( + "asic {} {:?}: locked={} coarsecon={} fincon={:#04x} fincon_valid={}", + asic, dll, status.locked, status.coarsecon, status.fincon, status.fincon_valid + ); + } + + Ok(()) +} + +async fn write_engine_reg_all_u8( + uart: &mut Bzm2UartController, + asic: u8, + offset: u8, + value: u8, +) -> Result<()> { + for row in 0..20u16 { + uart.multicast_write_reg_u8(asic, row, offset, value) + .await?; + } + Ok(()) +} + +async fn write_engine_reg_all_u32( + uart: &mut Bzm2UartController, + asic: u8, + offset: u8, + value: u32, +) -> Result<()> { + for row in 0..20u16 { + uart.multicast_write_register(asic, row, offset, &value.to_le_bytes()) + .await?; + } + Ok(()) +} + +async fn dispatch_grid_jobs( + uart: &mut Bzm2UartController, + asic: u8, + sequence_base: u8, + ntime: u32, + timestamp_count: u8, + second_phase: bool, +) -> Result<()> { + write_engine_reg_all_u8(uart, asic, ENGINE_REG_TIMESTAMP_COUNT, timestamp_count).await?; + + for (index, (row, col)) in default_engine_coordinates().into_iter().enumerate() { + let engine = logical_engine_address(row, col); + let sequence = sequence_base.wrapping_add(index as u8); + let (midstate, merkle_root_residue) = + synthetic_job_material(asic, engine, sequence, second_phase); + uart.write_job( + asic, + engine, + &midstate, + merkle_root_residue, + ntime.wrapping_add(u32::from(second_phase)), + sequence, + 0, + ) + .await?; + } + + Ok(()) +} + +async fn watch_tdm_frames( + reader: &mut SerialReader, + parser: &mut TdmFrameParser, + duration: Duration, +) -> Result { + let deadline = Instant::now() + duration; + let mut stats = TdmStats::default(); + + while Instant::now() < deadline { + let frames = + read_tdm_frames_once(reader, parser, Duration::from_millis(DEFAULT_WATCH_POLL_MS)) + .await?; + for frame in frames { + stats.record(&frame); + print_tdm_frame(&frame); + } + } + + Ok(stats) +} + +async fn read_tdm_frames_once( + reader: &mut SerialReader, + parser: &mut TdmFrameParser, + wait: Duration, +) -> Result> { + let mut buf = [0u8; 1024]; + match timeout(wait, reader.read(&mut buf)).await { + Ok(Ok(0)) => bail!("serial stream closed while waiting for TDM data"), + Ok(Ok(read)) => Ok(parser.push(&buf[..read])), + Ok(Err(err)) => Err(err).context("failed to read TDM data"), + Err(_) => Ok(Vec::new()), + } +} +fn print_tdm_frame(frame: &TdmFrame) { + match frame { + TdmFrame::Result(result) => { + println!( + "tdm result: asic={} engine={:#05x} row={} col={} status={:#x} nonce={:#010x} seq={} time={}", + result.asic, + result.engine_address, + result.row(), + result.col(), + result.status, + result.nonce, + result.sequence_id, + result.reported_time + ); + } + TdmFrame::Register(register) => { + println!( + "tdm readreg: asic={} data={}", + register.asic, + hex::encode(®ister.data) + ); + } + TdmFrame::Noop(noop) => { + let ascii = String::from_utf8_lossy(&noop.data); + println!( + "tdm noop: asic={} ascii={} hex={}", + noop.asic, + ascii, + hex::encode(noop.data) + ); + } + TdmFrame::DtsVs(dts_vs) => match dts_vs { + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { + println!( + "tdm dts_vs gen1: asic={} voltage={} thermal_tune_code={} voltage_enabled={} thermal_validity={} thermal_enabled={}", + frame.asic, + frame.voltage, + frame.thermal_tune_code, + frame.voltage_enabled, + frame.thermal_validity, + frame.thermal_enabled + ); + } + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { + println!( + "tdm dts_vs gen2: asic={} ch0={} ch1={} ch2={} thermal_code={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", + frame.asic, + frame.ch0_voltage, + frame.ch1_voltage, + frame.ch2_voltage, + frame.thermal_tune_code, + frame.thermal_trip_status, + frame.thermal_fault, + frame.voltage_fault, + frame.pll_lock, + frame.dll0_lock, + frame.dll1_lock + ); + } + }, + } +} + +fn print_dts_vs_query_frame(frame: &mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame) { + match frame { + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen1(frame) => { + println!( + "asic={} gen=1 voltage_v={} temperature_c={} thermal_valid={} voltage_enabled={}", + frame.asic, + frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.voltage))) + .unwrap_or_else(|| "n/a".into()), + if frame.thermal_enabled && frame.thermal_validity { + format!( + "{:.2}", + legacy_gen1_tune_code_to_temperature_c(frame.thermal_tune_code) + ) + } else { + "n/a".into() + }, + frame.thermal_validity, + frame.voltage_enabled, + ); + } + mujina_miner::asic::bzm2::protocol::TdmDtsVsFrame::Gen2(frame) => { + let temperature = if frame.thermal_enabled && frame.thermal_validity { + format!( + "{:.2}", + legacy_tune_code_to_temperature_c(frame.thermal_tune_code) + ) + } else { + "n/a".into() + }; + let ch0 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch0_voltage))) + .unwrap_or_else(|| "n/a".into()); + let ch1 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch1_voltage))) + .unwrap_or_else(|| "n/a".into()); + let ch2 = frame + .voltage_enabled + .then(|| format!("{:.4}", legacy_tune_code_to_voltage_v(frame.ch2_voltage))) + .unwrap_or_else(|| "n/a".into()); + println!( + "asic={} gen=2 temperature_c={} voltage_ch0_v={} voltage_ch1_v={} voltage_ch2_v={} thermal_trip={} thermal_fault={} voltage_fault={} pll_lock={} dll0_lock={} dll1_lock={}", + frame.asic, + temperature, + ch0, + ch1, + ch2, + frame.thermal_trip_status, + frame.thermal_fault, + frame.voltage_fault, + frame.pll_lock, + frame.dll0_lock, + frame.dll1_lock, + ); + } + } +} + +fn legacy_gen1_tune_code_to_temperature_c(tune_code: u8) -> f32 { + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 +} + +fn legacy_tune_code_to_temperature_c(tune_code: u16) -> f32 { + -293.8 + 631.8 * ((tune_code as f32) - (2048.0 / 4096.0)) / 4096.0 +} + +fn legacy_tune_code_to_voltage_v(tune_code: u16) -> f32 { + 0.4 * 0.7067 * (6.0 * (tune_code as f32) / 16384.0 - 3.0 / 16384.0 - 1.0) +} + +fn synthetic_job_material( + asic: u8, + engine_address: u16, + sequence_id: u8, + second_phase: bool, +) -> ([u8; 32], u32) { + let phase = if second_phase { 1u8 } else { 0u8 }; + let seed = format!("{asic}:{engine_address}:{sequence_id}:{phase}"); + let digest = Sha256::digest(seed.as_bytes()); + let residue_digest = Sha256::digest(digest); + + let mut midstate = [0u8; 32]; + midstate.copy_from_slice(&digest); + + let mut residue_bytes = [0u8; 4]; + residue_bytes.copy_from_slice(&residue_digest[..4]); + let merkle_root_residue = u32::from_le_bytes(residue_bytes); + + (midstate, merkle_root_residue) +} + +fn synthetic_loopback_payload(asic: u8, payload_len: usize) -> Vec { + let mut payload = Vec::with_capacity(payload_len); + let mut block = Sha256::digest([asic]); + while payload.len() < payload_len { + let take = (payload_len - payload.len()).min(block.len()); + payload.extend_from_slice(&block[..take]); + block = Sha256::digest(block); + } + payload +} + +fn encode_tdm_control(prediv_raw: u32, counter: u8, enable: bool) -> u32 { + (prediv_raw << 9) | ((counter as u32) << 1) | u32::from(enable) +} + +fn format_asic_ids(asics: &[u8]) -> String { + asics + .iter() + .map(|asic| asic.to_string()) + .collect::>() + .join(",") +} + +fn format_engine_coordinates(coords: &[mujina_miner::asic::bzm2::Bzm2EngineCoordinate]) -> String { + coords + .iter() + .map(|coord| { + format!( + "r{}c{}(0x{:03x})", + coord.row, coord.col, coord.engine_address + ) + }) + .collect::>() + .join(",") +} + +#[derive(Debug, Default, Clone, Copy)] +struct TdmStats { + result: usize, + register: usize, + noop: usize, + dts_vs: usize, +} + +impl TdmStats { + fn record(&mut self, frame: &TdmFrame) { + match frame { + TdmFrame::Result(_) => self.result += 1, + TdmFrame::Register(_) => self.register += 1, + TdmFrame::Noop(_) => self.noop += 1, + TdmFrame::DtsVs(_) => self.dts_vs += 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zeros_to_find_is_range_checked() { + assert!(parse_zeros_to_find("32").is_ok()); + assert!(parse_zeros_to_find("64").is_ok()); + assert!(parse_zeros_to_find("31").is_err()); + assert!(parse_zeros_to_find("65").is_err()); + } + + #[test] + fn tdm_control_word_matches_legacy_layout() { + let control = encode_tdm_control(0x12, 0x0f, true); + assert_eq!(control, (0x12 << 9) | (0x0f << 1) | 1); + } + + #[test] + fn synthetic_jobs_change_per_engine() { + let first = synthetic_job_material(2, logical_engine_address(0, 0), 0, false); + let second = synthetic_job_material(2, logical_engine_address(1, 0), 1, false); + let third = synthetic_job_material(2, logical_engine_address(0, 0), 0, true); + + assert_ne!(first, second); + assert_ne!(first, third); + } +} diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 1c9bc11e..eb1856bd 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -808,6 +808,8 @@ impl BitaxeBoard { }, ], threads: Vec::new(), + asics: Vec::new(), + bzm2_tuning: None, }); // -- Log summary (throttled) -- @@ -998,7 +1000,10 @@ async fn create_from_usb( board.chip_count() ); - let registration = super::BoardRegistration { state_rx }; + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/bzm2.rs b/mujina-miner/src/board/bzm2.rs new file mode 100644 index 00000000..77e84680 --- /dev/null +++ b/mujina-miner/src/board/bzm2.rs @@ -0,0 +1,3858 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; + +use super::{Board, BoardCommand, BoardError, BoardInfo, VirtualBoardDescriptor}; +use crate::{ + api_client::types::{ + AsicState, BoardState, Bzm2AsicTuningState, Bzm2DomainTuningState, Bzm2PllTuningState, + Bzm2TuningState, EngineCoordinate, Fan, PowerMeasurement, TemperatureSensor, ThreadState, + }, + asic::{ + bzm2::{ + Bzm2AsicMeasurement, Bzm2AsicTopology, Bzm2BoardCalibrationInput, Bzm2BringupPlan, + Bzm2CalibrationConstraints, Bzm2CalibrationMode, Bzm2CalibrationPlanner, + Bzm2ClockController, Bzm2DiscoveredEngineMap, Bzm2DomainMeasurement, + Bzm2OperatingClass, Bzm2PerformanceMode, Bzm2Pll, Bzm2SavedEngineCoordinate, + Bzm2SavedEngineTopology, Bzm2SavedOperatingPoint, Bzm2Thread, Bzm2ThreadConfig, + Bzm2ThreadHandle, Bzm2ThreadRuntimeMetrics, Bzm2UartController, Bzm2VoltageDomain, + FileGpioPin, FilePowerRail, GpioResetLine, VoltageStackStep, control::Bzm2PowerRail, + }, + hash_thread::{ + HashTask, HashThread, HashThreadCapabilities, HashThreadError, HashThreadEvent, + HashThreadStatus, HashThreadTelemetryUpdate, + }, + }, + tracing::prelude::*, + transport::{SerialControl, SerialStream}, +}; + +const DEFAULT_BAUD_RATE: u32 = 5_000_000; +const DEFAULT_DISPATCH_INTERVAL_MS: u64 = 500; +const DEFAULT_NOMINAL_HASHRATE_THS: f64 = 40.0; +const DEFAULT_TELEMETRY_INTERVAL_SECS: u64 = 5; +const DEFAULT_ASIC_TEMP_SCALE: f32 = 0.001; +const DEFAULT_BOARD_TEMP_SCALE: f32 = 0.001; +const DEFAULT_FAN_RPM_SCALE: f32 = 1.0; +const DEFAULT_FAN_PERCENT_SCALE: f32 = 1.0; +const DEFAULT_VOLTAGE_SCALE: f32 = 0.001; +const DEFAULT_CURRENT_SCALE: f32 = 0.001; +const DEFAULT_POWER_SCALE: f32 = 0.000001; +const DEFAULT_CALIBRATION_SITE_TEMP_C: f32 = 20.0; +const DEFAULT_CALIBRATION_POST1_DIVIDER: u8 = 0; +const DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS: u64 = 1_000; +const DEFAULT_CALIBRATION_LOCK_POLL_MS: u64 = 100; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW: u32 = 0x0f; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER: u8 = 16; +const DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; +const DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS: u16 = 100; +const DEFAULT_BRINGUP_PRE_POWER_MS: u64 = 10; +const DEFAULT_BRINGUP_POST_POWER_MS: u64 = 25; +const DEFAULT_BRINGUP_RELEASE_RESET_MS: u64 = 25; +const DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS: u64 = 100; + +#[derive(Debug, Clone)] +pub struct Bzm2VirtualDeviceConfig { + pub serial_paths: Vec, + pub baud_rate: u32, + pub timestamp_count: u8, + pub nonce_gap: u32, + pub dispatch_interval: Duration, + pub nominal_hashrate_ths: f64, + pub dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration, + pub telemetry: Bzm2TelemetryConfig, + pub calibration: Bzm2CalibrationConfig, + pub enumeration: Bzm2EnumerationConfig, + pub bringup: Bzm2BringupConfig, +} + +impl Bzm2VirtualDeviceConfig { + pub fn from_env() -> Option { + let raw_paths = env::var("MUJINA_BZM2_SERIAL") + .ok() + .or_else(|| env::var("MUJINA_BZM2_SERIAL_PATHS").ok())?; + + let serial_paths: Vec = raw_paths + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if serial_paths.is_empty() { + return None; + } + + let baud_rate = env::var("MUJINA_BZM2_BAUD") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BAUD_RATE); + let timestamp_count = env::var("MUJINA_BZM2_TIMESTAMP_COUNT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT); + let nonce_gap = env::var("MUJINA_BZM2_NONCE_GAP") + .ok() + .and_then(|value| parse_u32(&value)) + .unwrap_or(crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP); + let dispatch_interval = Duration::from_millis( + env::var("MUJINA_BZM2_DISPATCH_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_DISPATCH_INTERVAL_MS), + ); + let nominal_hashrate_ths = env::var("MUJINA_BZM2_HASHRATE_THS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_NOMINAL_HASHRATE_THS); + let dts_vs_generation = env::var("MUJINA_BZM2_DTS_VS_GEN") + .ok() + .as_deref() + .and_then(crate::asic::bzm2::protocol::DtsVsGeneration::from_env_value) + .unwrap_or(crate::asic::bzm2::protocol::DtsVsGeneration::Gen2); + let calibration = Bzm2CalibrationConfig::from_env(serial_paths.len()); + let bringup = Bzm2BringupConfig::from_env(); + + Some(Self { + serial_paths: serial_paths.clone(), + baud_rate, + timestamp_count, + nonce_gap, + dispatch_interval, + nominal_hashrate_ths, + dts_vs_generation, + telemetry: Bzm2TelemetryConfig::from_env(), + enumeration: Bzm2EnumerationConfig::from_env(serial_paths.len(), &calibration), + bringup, + calibration, + }) + } + + pub fn device_id(&self) -> String { + let suffix = self + .serial_paths + .iter() + .map(|path| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::() + }) + .collect::>() + .join("-"); + format!("bzm2-{}", suffix) + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2EnumerationConfig { + pub enabled: bool, + pub start_id: u8, + pub max_asics_per_bus: Vec, +} + +impl Default for Bzm2EnumerationConfig { + fn default() -> Self { + Self { + enabled: false, + start_id: 0, + max_asics_per_bus: vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS], + } + } +} + +impl Bzm2EnumerationConfig { + fn from_env(serial_count: usize, calibration: &Bzm2CalibrationConfig) -> Self { + let mut max_asics_per_bus = parse_csv_numbers::("MUJINA_BZM2_ENUM_MAX_ASICS_PER_BUS") + .unwrap_or_else(|| { + if calibration.asics_per_bus.iter().any(|count| *count > 1) { + calibration.asics_per_bus.clone() + } else if serial_count == 0 { + Vec::new() + } else { + vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS; serial_count] + } + }); + if max_asics_per_bus.is_empty() && serial_count > 0 { + max_asics_per_bus = vec![DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS; serial_count]; + } + + Self { + enabled: env_flag_any(&["MUJINA_BZM2_ENUMERATE_CHAIN", "MUJINA_BZM2_AUTO_ENUMERATE"]), + start_id: env::var("MUJINA_BZM2_ENUM_START_ID") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + max_asics_per_bus, + } + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2BringupConfig { + pub enabled: bool, + pub rail_set_paths: Vec, + pub rail_write_scales: Vec, + pub domain_rail_indices: Vec, + pub rail_enable_paths: Vec, + pub rail_enable_values: Vec, + pub rail_vin: Vec, + pub rail_vout: Vec, + pub rail_current: Vec, + pub rail_power: Vec, + pub rail_temperature: Vec, + pub reset_path: Option, + pub reset_active_low: bool, + pub plan: Bzm2BringupPlan, +} + +impl Default for Bzm2BringupConfig { + fn default() -> Self { + Self { + enabled: false, + rail_set_paths: Vec::new(), + rail_write_scales: Vec::new(), + domain_rail_indices: Vec::new(), + rail_enable_paths: Vec::new(), + rail_enable_values: Vec::new(), + rail_vin: Vec::new(), + rail_vout: Vec::new(), + rail_current: Vec::new(), + rail_power: Vec::new(), + rail_temperature: Vec::new(), + reset_path: None, + reset_active_low: true, + plan: Bzm2BringupPlan { + pre_power_delay: Duration::from_millis(DEFAULT_BRINGUP_PRE_POWER_MS), + post_power_delay: Duration::from_millis(DEFAULT_BRINGUP_POST_POWER_MS), + release_reset_delay: Duration::from_millis(DEFAULT_BRINGUP_RELEASE_RESET_MS), + ..Default::default() + }, + } + } +} + +impl Bzm2BringupConfig { + fn from_env() -> Self { + let rail_set_paths = env_csv_strings_any(&[ + "MUJINA_BZM2_RAIL_SET_PATHS", + "MUJINA_BZM2_BRINGUP_RAIL_SET_PATHS", + ]); + let rail_target_volts = parse_csv_numbers::("MUJINA_BZM2_RAIL_TARGET_VOLTS") + .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_TARGET_VOLTS")) + .unwrap_or_default(); + let rail_write_scales = parse_csv_numbers::("MUJINA_BZM2_RAIL_WRITE_SCALES") + .or_else(|| parse_csv_numbers::("MUJINA_BZM2_BRINGUP_RAIL_WRITE_SCALES")) + .unwrap_or_default(); + let domain_rail_indices = + parse_csv_numbers_any::(&["MUJINA_BZM2_DOMAIN_RAIL_INDICES"]) + .unwrap_or_default(); + let rail_enable_paths = env_csv_strings_any(&[ + "MUJINA_BZM2_RAIL_ENABLE_PATHS", + "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_PATHS", + ]); + let rail_enable_values = env_csv_strings_any(&[ + "MUJINA_BZM2_RAIL_ENABLE_VALUES", + "MUJINA_BZM2_BRINGUP_RAIL_ENABLE_VALUES", + ]); + let rail_vin = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_VIN_PATHS"], + &["MUJINA_BZM2_RAIL_VIN_SCALES"], + DEFAULT_VOLTAGE_SCALE, + ); + let rail_vout = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_VOUT_PATHS"], + &["MUJINA_BZM2_RAIL_VOUT_SCALES"], + DEFAULT_VOLTAGE_SCALE, + ); + let rail_current = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_CURRENT_PATHS"], + &["MUJINA_BZM2_RAIL_CURRENT_SCALES"], + DEFAULT_CURRENT_SCALE, + ); + let rail_power = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_POWER_PATHS"], + &["MUJINA_BZM2_RAIL_POWER_SCALES"], + DEFAULT_POWER_SCALE, + ); + let rail_temperature = sensor_specs_from_env( + &["MUJINA_BZM2_RAIL_TEMP_PATHS"], + &["MUJINA_BZM2_RAIL_TEMP_SCALES"], + DEFAULT_BOARD_TEMP_SCALE, + ); + let reset_path = env_var_any(&["MUJINA_BZM2_RESET_PATH", "MUJINA_BZM2_BRINGUP_RESET_PATH"]); + let enabled = env_flag_any(&["MUJINA_BZM2_ENABLE_BRINGUP", "MUJINA_BZM2_BRINGUP_ENABLE"]) + || !rail_set_paths.is_empty() + || reset_path.is_some(); + + let mut plan = Bzm2BringupPlan { + assert_reset_before_power: env_flag_default_any( + &["MUJINA_BZM2_ASSERT_RESET_BEFORE_POWER"], + true, + ), + pre_power_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_PRE_POWER_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_PRE_POWER_MS), + ), + post_power_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_POST_POWER_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_POST_POWER_MS), + ), + release_reset_delay: Duration::from_millis( + env::var("MUJINA_BZM2_BRINGUP_RELEASE_RESET_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BRINGUP_RELEASE_RESET_MS), + ), + ..Default::default() + }; + plan.steps = rail_set_paths + .iter() + .enumerate() + .filter_map(|(index, _)| { + rail_target_volts + .get(index) + .or_else(|| rail_target_volts.last()) + .copied() + .map(|voltage| VoltageStackStep { + rail_index: index, + voltage, + settle_for: Duration::ZERO, + }) + }) + .collect(); + + Self { + enabled, + rail_set_paths, + rail_write_scales, + domain_rail_indices, + rail_enable_paths, + rail_enable_values, + rail_vin, + rail_vout, + rail_current, + rail_power, + rail_temperature, + reset_path, + reset_active_low: env_flag_default_any(&["MUJINA_BZM2_RESET_ACTIVE_LOW"], true), + plan, + } + } + + fn build_rails(&self) -> Vec { + self.rail_set_paths + .iter() + .enumerate() + .map(|(index, path)| { + let write_scale = *self + .rail_write_scales + .get(index) + .or_else(|| self.rail_write_scales.last()) + .unwrap_or(&1.0); + let mut rail = FilePowerRail::new(path.clone(), write_scale); + if let Some(enable_path) = self + .rail_enable_paths + .get(index) + .or_else(|| self.rail_enable_paths.last()) + { + let enable_value = self + .rail_enable_values + .get(index) + .or_else(|| self.rail_enable_values.last()) + .cloned() + .unwrap_or_else(|| "1".into()); + rail = rail.with_enable(enable_path.clone(), enable_value); + } + rail + }) + .collect() + } + + fn build_reset_line(&self) -> Option> { + self.reset_path.as_ref().map(|path| { + GpioResetLine::new( + FileGpioPin::new(path.clone(), "1", "0"), + self.reset_active_low, + ) + }) + } + + fn rail_index_for_domain(&self, domain_id: u16) -> Option { + self.domain_rail_indices + .get(domain_id as usize) + .copied() + .or_else(|| { + let fallback = domain_id as usize; + (fallback < self.rail_set_paths.len()).then_some(fallback) + }) + } + + fn has_telemetry(&self) -> bool { + !self.rail_vin.is_empty() + || !self.rail_vout.is_empty() + || !self.rail_current.is_empty() + || !self.rail_power.is_empty() + || !self.rail_temperature.is_empty() + } + + fn snapshot_telemetry(&self) -> Bzm2TelemetrySnapshot { + let rail_count = [ + self.rail_set_paths.len(), + self.rail_vin.len(), + self.rail_vout.len(), + self.rail_current.len(), + self.rail_power.len(), + self.rail_temperature.len(), + ] + .into_iter() + .max() + .unwrap_or(0); + + let mut temperatures = Vec::new(); + let mut powers = Vec::new(); + for index in 0..rail_count { + let vin = self.rail_vin.get(index).and_then(SensorSpec::read); + let vout = self.rail_vout.get(index).and_then(SensorSpec::read); + let current = self.rail_current.get(index).and_then(SensorSpec::read); + let power = self + .rail_power + .get(index) + .and_then(SensorSpec::read) + .or_else(|| match (vout, current) { + (Some(voltage_v), Some(current_a)) => Some(voltage_v * current_a), + _ => None, + }); + let temperature_c = self.rail_temperature.get(index).and_then(SensorSpec::read); + + if let Some(temperature_c) = temperature_c { + temperatures.push(TemperatureSensor { + name: format!("rail{}-regulator", index), + temperature_c: Some(temperature_c), + }); + } + if vin.is_some() { + powers.push(PowerMeasurement { + name: format!("rail{}-input", index), + voltage_v: vin, + current_a: None, + power_w: None, + }); + } + if vout.is_some() || current.is_some() || power.is_some() { + powers.push(PowerMeasurement { + name: format!("rail{}-output", index), + voltage_v: vout, + current_a: current, + power_w: power, + }); + } + } + + Bzm2TelemetrySnapshot { + fans: Vec::new(), + temperatures, + powers, + trip_reason: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct Bzm2CalibrationConfig { + pub enabled: bool, + pub apply_saved_operating_point: bool, + pub discover_engine_topology: bool, + pub operating_class: Bzm2OperatingClass, + pub performance_mode: Bzm2PerformanceMode, + pub mode: Bzm2CalibrationMode, + pub per_stack_clocking: bool, + pub force_retune: bool, + pub asics_per_bus: Vec, + pub asics_per_domain: Vec, + pub domain_voltage_offsets_mv: Vec, + pub profile_path: Option, + pub site_temp_c: Option, + pub pll_post1_divider: u8, + pub skip_lock_check: bool, + pub lock_timeout: Duration, + pub lock_poll_interval: Duration, + pub engine_discovery_tdm_prediv_raw: u32, + pub engine_discovery_tdm_counter: u8, + pub engine_discovery_timeout: Duration, +} + +impl Default for Bzm2CalibrationConfig { + fn default() -> Self { + Self { + enabled: false, + apply_saved_operating_point: true, + discover_engine_topology: true, + operating_class: Bzm2OperatingClass::Generic, + performance_mode: Bzm2PerformanceMode::Standard, + mode: Bzm2CalibrationMode::default(), + per_stack_clocking: false, + force_retune: false, + asics_per_bus: vec![1], + asics_per_domain: vec![1], + domain_voltage_offsets_mv: Vec::new(), + profile_path: None, + site_temp_c: None, + pll_post1_divider: DEFAULT_CALIBRATION_POST1_DIVIDER, + skip_lock_check: false, + lock_timeout: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS), + lock_poll_interval: Duration::from_millis(DEFAULT_CALIBRATION_LOCK_POLL_MS), + engine_discovery_tdm_prediv_raw: DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW, + engine_discovery_tdm_counter: DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER, + engine_discovery_timeout: Duration::from_millis( + DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS, + ), + } + } +} + +impl Bzm2CalibrationConfig { + fn from_env(serial_count: usize) -> Self { + let mut config = Self { + enabled: env_flag("MUJINA_BZM2_CALIBRATE") || env_flag("MUJINA_BZM2_ENABLE_PNP"), + apply_saved_operating_point: env_flag_default_any( + &[ + "MUJINA_BZM2_APPLY_SAVED_OPERATING_POINT", + "MUJINA_BZM2_REPLAY_STORED_CALIBRATION", + ], + true, + ), + discover_engine_topology: env_flag_default_any( + &[ + "MUJINA_BZM2_CALIBRATION_DISCOVER_ENGINES", + "MUJINA_BZM2_DISCOVER_ENGINES_FOR_CALIBRATION", + ], + true, + ), + operating_class: env_var_any(&["MUJINA_BZM2_OPERATING_CLASS", "MUJINA_BZM2_BOARD_BIN"]) + .as_deref() + .and_then(parse_operating_class) + .unwrap_or(Bzm2OperatingClass::Generic), + performance_mode: env_var_any(&[ + "MUJINA_BZM2_PERFORMANCE_MODE", + "MUJINA_BZM2_MINING_STRATEGY", + ]) + .as_deref() + .and_then(parse_performance_mode) + .unwrap_or(Bzm2PerformanceMode::Standard), + mode: Bzm2CalibrationMode { + sweep_strategy: env_flag_any(&[ + "MUJINA_BZM2_SWEEP_MODE", + "MUJINA_BZM2_SWEEP_STRATEGY", + ]), + sweep_voltage: env_flag("MUJINA_BZM2_SWEEP_VOLTAGE"), + sweep_frequency: env_flag("MUJINA_BZM2_SWEEP_FREQUENCY"), + sweep_pass_rate: env_flag("MUJINA_BZM2_SWEEP_PASS_RATE"), + }, + per_stack_clocking: env_flag_any(&[ + "MUJINA_BZM2_PER_STACK_CLOCKING", + "MUJINA_BZM2_SPLIT_STACK_FREQUENCY", + ]), + force_retune: env_flag_any(&[ + "MUJINA_BZM2_FORCE_RETUNE", + "MUJINA_BZM2_FORCE_RECALIBRATION", + ]), + asics_per_bus: parse_csv_numbers::("MUJINA_BZM2_ASICS_PER_BUS").unwrap_or_else( + || { + if serial_count == 0 { + Vec::new() + } else { + vec![1; serial_count] + } + }, + ), + asics_per_domain: parse_csv_numbers::("MUJINA_BZM2_ASICS_PER_DOMAIN") + .unwrap_or_else(|| vec![1]), + domain_voltage_offsets_mv: parse_csv_numbers::( + "MUJINA_BZM2_DOMAIN_VOLTAGE_OFFSETS_MV", + ) + .unwrap_or_default(), + profile_path: env_var_any(&[ + "MUJINA_BZM2_SAVED_OPERATING_POINT_PATH", + "MUJINA_BZM2_CALIBRATION_PROFILE", + ]) + .map(PathBuf::from), + site_temp_c: env_f32_any(&["MUJINA_BZM2_SITE_TEMP_C", "MUJINA_BZM2_AMBIENT_TEMP_C"]), + pll_post1_divider: env::var("MUJINA_BZM2_CALIBRATION_POST1_DIVIDER") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_POST1_DIVIDER), + skip_lock_check: env_flag("MUJINA_BZM2_CALIBRATION_SKIP_LOCK_CHECK"), + lock_timeout: Duration::from_millis( + env::var("MUJINA_BZM2_CALIBRATION_LOCK_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_LOCK_TIMEOUT_MS), + ), + lock_poll_interval: Duration::from_millis( + env::var("MUJINA_BZM2_CALIBRATION_LOCK_POLL_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_CALIBRATION_LOCK_POLL_MS), + ), + engine_discovery_tdm_prediv_raw: env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TDM_PREDIV_RAW", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW", + ]) + .as_deref() + .and_then(parse_u32_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_PREDIV_RAW), + engine_discovery_tdm_counter: env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TDM_COUNTER", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER", + ]) + .as_deref() + .and_then(parse_u8_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TDM_COUNTER), + engine_discovery_timeout: Duration::from_millis( + env_var_any(&[ + "MUJINA_BZM2_ENGINE_DISCOVERY_TIMEOUT_MS", + "MUJINA_BZM2_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS", + ]) + .as_deref() + .and_then(parse_u64_any_radix) + .unwrap_or(DEFAULT_CALIBRATION_ENGINE_DISCOVERY_TIMEOUT_MS), + ), + }; + + if config.asics_per_bus.is_empty() && serial_count > 0 { + config.asics_per_bus = vec![1; serial_count]; + } + if config.asics_per_domain.is_empty() { + config.asics_per_domain = vec![1]; + } + + config + } +} + +#[derive(Debug, Clone, Default)] +pub struct Bzm2TelemetryConfig { + pub poll_interval: Duration, + pub asic_temp: Option, + pub board_temp: Option, + pub fan_rpm: Option, + pub fan_percent: Option, + pub input_voltage: Option, + pub input_current: Option, + pub input_power: Option, + pub max_asic_temp_c: Option, + pub max_board_temp_c: Option, + pub max_input_power_w: Option, +} +impl Bzm2TelemetryConfig { + fn from_env() -> Self { + Self { + poll_interval: Duration::from_secs( + env::var("MUJINA_BZM2_TELEMETRY_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_TELEMETRY_INTERVAL_SECS), + ), + asic_temp: SensorSpec::from_env( + "MUJINA_BZM2_ASIC_TEMP_PATH", + "MUJINA_BZM2_ASIC_TEMP_SCALE", + DEFAULT_ASIC_TEMP_SCALE, + ), + board_temp: SensorSpec::from_env( + "MUJINA_BZM2_BOARD_TEMP_PATH", + "MUJINA_BZM2_BOARD_TEMP_SCALE", + DEFAULT_BOARD_TEMP_SCALE, + ), + fan_rpm: SensorSpec::from_env( + "MUJINA_BZM2_FAN_RPM_PATH", + "MUJINA_BZM2_FAN_RPM_SCALE", + DEFAULT_FAN_RPM_SCALE, + ), + fan_percent: SensorSpec::from_env( + "MUJINA_BZM2_FAN_PERCENT_PATH", + "MUJINA_BZM2_FAN_PERCENT_SCALE", + DEFAULT_FAN_PERCENT_SCALE, + ), + input_voltage: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_VOLTAGE_PATH", + "MUJINA_BZM2_INPUT_VOLTAGE_SCALE", + DEFAULT_VOLTAGE_SCALE, + ), + input_current: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_CURRENT_PATH", + "MUJINA_BZM2_INPUT_CURRENT_SCALE", + DEFAULT_CURRENT_SCALE, + ), + input_power: SensorSpec::from_env( + "MUJINA_BZM2_INPUT_POWER_PATH", + "MUJINA_BZM2_INPUT_POWER_SCALE", + DEFAULT_POWER_SCALE, + ), + max_asic_temp_c: env_f32("MUJINA_BZM2_MAX_ASIC_TEMP_C"), + max_board_temp_c: env_f32("MUJINA_BZM2_MAX_BOARD_TEMP_C"), + max_input_power_w: env_f32("MUJINA_BZM2_MAX_INPUT_POWER_W"), + } + } + + fn is_enabled(&self) -> bool { + self.asic_temp.is_some() + || self.board_temp.is_some() + || self.fan_rpm.is_some() + || self.fan_percent.is_some() + || self.input_voltage.is_some() + || self.input_current.is_some() + || self.input_power.is_some() + || self.max_asic_temp_c.is_some() + || self.max_board_temp_c.is_some() + || self.max_input_power_w.is_some() + } + + fn snapshot(&self) -> Bzm2TelemetrySnapshot { + let asic_temp = self.asic_temp.as_ref().and_then(SensorSpec::read); + let board_temp = self.board_temp.as_ref().and_then(SensorSpec::read); + let fan_rpm = self + .fan_rpm + .as_ref() + .and_then(SensorSpec::read) + .map(|v| v.round() as u32); + let fan_percent = self + .fan_percent + .as_ref() + .and_then(SensorSpec::read) + .map(|v| v.round().clamp(0.0, 100.0) as u8); + let voltage_v = self.input_voltage.as_ref().and_then(SensorSpec::read); + let current_a = self.input_current.as_ref().and_then(SensorSpec::read); + let power_w = self + .input_power + .as_ref() + .and_then(SensorSpec::read) + .or_else(|| match (voltage_v, current_a) { + (Some(voltage_v), Some(current_a)) => Some(voltage_v * current_a), + _ => None, + }); + + let fans = if fan_rpm.is_some() || fan_percent.is_some() { + vec![Fan { + name: "fan".into(), + rpm: fan_rpm, + percent: fan_percent, + target_percent: None, + }] + } else { + Vec::new() + }; + + let mut temperatures = Vec::new(); + if self.asic_temp.is_some() || asic_temp.is_some() { + temperatures.push(TemperatureSensor { + name: "asic".into(), + temperature_c: asic_temp, + }); + } + if self.board_temp.is_some() || board_temp.is_some() { + temperatures.push(TemperatureSensor { + name: "board".into(), + temperature_c: board_temp, + }); + } + + let powers = if self.input_voltage.is_some() + || self.input_current.is_some() + || self.input_power.is_some() + || power_w.is_some() + { + vec![PowerMeasurement { + name: "input".into(), + voltage_v, + current_a, + power_w, + }] + } else { + Vec::new() + }; + + let trip_reason = self.trip_reason(asic_temp, board_temp, power_w); + Bzm2TelemetrySnapshot { + fans, + temperatures, + powers, + trip_reason, + } + } + + fn trip_reason( + &self, + asic_temp: Option, + board_temp: Option, + input_power_w: Option, + ) -> Option { + if let (Some(limit), Some(value)) = (self.max_asic_temp_c, asic_temp) { + if value > limit { + return Some(format!( + "ASIC temperature {:.1}C exceeded limit {:.1}C", + value, limit + )); + } + } + if let (Some(limit), Some(value)) = (self.max_board_temp_c, board_temp) { + if value > limit { + return Some(format!( + "Board temperature {:.1}C exceeded limit {:.1}C", + value, limit + )); + } + } + if let (Some(limit), Some(value)) = (self.max_input_power_w, input_power_w) { + if value > limit { + return Some(format!( + "Input power {:.1}W exceeded limit {:.1}W", + value, limit + )); + } + } + None + } +} + +#[derive(Debug, Clone)] +pub struct SensorSpec { + pub path: String, + pub scale: f32, +} + +impl SensorSpec { + fn from_env(path_var: &str, scale_var: &str, default_scale: f32) -> Option { + let path = env::var(path_var).ok()?; + let scale = env::var(scale_var) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default_scale); + Some(Self { path, scale }) + } + + fn read(&self) -> Option { + let raw = fs::read_to_string(&self.path).ok()?; + parse_scaled_sensor_value(&raw, self.scale) + } +} + +#[derive(Debug, Clone, Default)] +struct Bzm2TelemetrySnapshot { + fans: Vec, + temperatures: Vec, + powers: Vec, + trip_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Bzm2BusLayout { + serial_path: String, + asic_start: u16, + asic_count: u16, +} + +impl Bzm2BusLayout { + fn contains(&self, global_asic_id: u16) -> bool { + global_asic_id >= self.asic_start && global_asic_id < self.asic_start + self.asic_count + } + + fn global_asic_id(&self, local_asic_id: u8) -> Option { + (u16::from(local_asic_id) < self.asic_count) + .then_some(self.asic_start + u16::from(local_asic_id)) + } + + fn local_asic_id(&self, global_asic_id: u16) -> Option { + self.contains(global_asic_id) + .then_some((global_asic_id - self.asic_start) as u8) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Bzm2PersistedCalibrationProfile { + schema_version: u32, + #[serde(alias = "board_bin")] + operating_class: String, + #[serde(alias = "strategy")] + performance_mode: String, + asics_per_bus: Vec, + pll_post1_divider: u8, + #[serde(alias = "calibration")] + saved_state: Bzm2SavedOperatingPoint, +} + +impl Bzm2PersistedCalibrationProfile { + const SCHEMA_VERSION: u32 = 1; + + fn is_compatible( + &self, + calibration: &Bzm2CalibrationConfig, + bus_layouts: &[Bzm2BusLayout], + ) -> bool { + self.schema_version == Self::SCHEMA_VERSION + && self.operating_class == operating_class_name(calibration.operating_class) + && self.performance_mode == performance_mode_name(calibration.performance_mode) + && self.pll_post1_divider == calibration.pll_post1_divider + && self.asics_per_bus + == bus_layouts + .iter() + .map(|bus| bus.asic_count) + .collect::>() + && self.saved_state.per_asic_pll_mhz.len() + == bus_layouts + .iter() + .map(|bus| bus.asic_count as usize) + .sum::() + } +} + +#[derive(Debug, Clone)] +struct Bzm2LoadedCalibrationProfile { + persisted: Option, + saved_state: Bzm2SavedOperatingPoint, +} + +#[derive(Debug, Clone, Default)] +struct Bzm2AppliedOperatingState { + per_domain_voltage_mv: BTreeMap, + per_asic_pll_mhz: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +struct Bzm2RuntimeMeasurementCache { + domain_measurements: BTreeMap, + asic_measurements: BTreeMap, +} + +pub struct Bzm2Board { + config: Bzm2VirtualDeviceConfig, + bringup_applied: bool, + shutdown_handles: Vec, + serial_controls: Vec, + bus_layouts: Arc>>, + applied_operating_state: Arc>, + runtime_measurements: Arc>, + state_tx: watch::Sender, + command_rx: Option>, + monitor_shutdown: Option>, + monitor_task: Option>, + command_shutdown: Option>, + command_task: Option>, +} + +impl Bzm2Board { + pub fn new( + config: Bzm2VirtualDeviceConfig, + state_tx: watch::Sender, + command_rx: mpsc::Receiver, + ) -> Self { + Self { + config, + bringup_applied: false, + shutdown_handles: Vec::new(), + serial_controls: Vec::new(), + bus_layouts: Arc::new(Mutex::new(Vec::new())), + applied_operating_state: Arc::new(Mutex::new(Bzm2AppliedOperatingState::default())), + runtime_measurements: Arc::new(Mutex::new(Bzm2RuntimeMeasurementCache::default())), + state_tx, + command_rx: Some(command_rx), + monitor_shutdown: None, + monitor_task: None, + command_shutdown: None, + command_task: None, + } + } + + async fn apply_bringup_sequence(&mut self) -> Result<(), BoardError> { + if self.bringup_applied || !self.config.bringup.enabled { + return Ok(()); + } + + let mut rails = self.config.bringup.build_rails(); + let mut reset_line = self.config.bringup.build_reset_line(); + self.config + .bringup + .plan + .apply(&mut rails, reset_line.as_mut()) + .await + .map_err(|err| { + BoardError::InitializationFailed(format!("BZM2 bring-up sequence failed: {err}")) + })?; + self.bringup_applied = true; + Ok(()) + } + + async fn apply_shutdown_sequence(&mut self) -> Result<(), BoardError> { + if !self.bringup_applied || !self.config.bringup.enabled { + return Ok(()); + } + + let mut rails = self.config.bringup.build_rails(); + let mut reset_line = self.config.bringup.build_reset_line(); + self.config + .bringup + .plan + .shutdown(&mut rails, reset_line.as_mut()) + .await + .map_err(|err| { + BoardError::HardwareControl(format!("BZM2 shutdown sequence failed: {err}")) + })?; + self.bringup_applied = false; + Ok(()) + } + + fn spawn_monitor(&mut self) { + if (!self.config.telemetry.is_enabled() && !self.config.bringup.has_telemetry()) + || self.monitor_task.is_some() + { + return; + } + + let telemetry = self.config.telemetry.clone(); + let rail_telemetry = self.config.bringup.clone(); + let calibration = self.config.calibration.clone(); + let state_tx = self.state_tx.clone(); + let shutdown_handles = self.shutdown_handles.clone(); + let serial_controls = self.serial_controls.clone(); + let bus_layouts = Arc::clone(&self.bus_layouts); + let applied_operating_state = Arc::clone(&self.applied_operating_state); + let runtime_measurements = Arc::clone(&self.runtime_measurements); + let board_name = self.config.device_id(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + self.monitor_shutdown = Some(shutdown_tx); + + self.monitor_task = Some(tokio::spawn(async move { + let mut interval = tokio::time::interval(telemetry.poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = interval.tick() => { + let snapshot = telemetry.snapshot(); + let rail_snapshot = rail_telemetry.snapshot_telemetry(); + let thread_metrics = collect_thread_runtime_metrics(&shutdown_handles).await; + let bus_layouts = bus_layouts.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let applied_operating_state = applied_operating_state.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let current_state = state_tx.borrow().clone(); + let (tuning_state, measurement_cache) = build_runtime_tuning_state( + ¤t_state.asics, + ¤t_state.temperatures, + &bus_layouts, + &calibration, + &rail_telemetry, + &rail_snapshot, + &applied_operating_state, + &thread_metrics, + ); + let runtime_domain_count = measurement_cache.domain_measurements.len(); + let runtime_asic_count = measurement_cache.asic_measurements.len(); + *runtime_measurements.lock().unwrap_or_else(|e| e.into_inner()) = measurement_cache; + let total_stats = serial_controls.iter().fold((0u64, 0u64), |acc, control| { + let stats = control.stats(); + (acc.0 + stats.bytes_read, acc.1 + stats.bytes_written) + }); + let _ = state_tx.send_modify(|state| { + state.fans = snapshot.fans.clone(); + merge_temperature_readings(&mut state.temperatures, &snapshot.temperatures); + merge_power_readings(&mut state.powers, &snapshot.powers); + merge_temperature_readings(&mut state.temperatures, &rail_snapshot.temperatures); + merge_power_readings(&mut state.powers, &rail_snapshot.powers); + state.bzm2_tuning = + (!tuning_state.asics.is_empty() || !tuning_state.domains.is_empty() + || tuning_state.board_throughput_hs.is_some()) + .then_some(tuning_state.clone()); + }); + trace!( + board = %board_name, + bytes_read = total_stats.0, + bytes_written = total_stats.1, + runtime_domain_count, + runtime_asic_count, + "BZM2 board telemetry updated" + ); + if let Some(reason) = snapshot.trip_reason.clone() { + warn!(board = %board_name, reason = %reason, "BZM2 safety trip triggered"); + for handle in &shutdown_handles { + handle.shutdown(); + } + let _ = state_tx.send_modify(|state| { + for thread in &mut state.threads { + thread.is_active = false; + thread.hashrate = 0; + } + }); + break; + } + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + })); + } + + fn spawn_command_loop(&mut self) { + if self.command_task.is_some() { + return; + } + let Some(mut command_rx) = self.command_rx.take() else { + return; + }; + + let state_tx = self.state_tx.clone(); + let shutdown_handles = self.shutdown_handles.clone(); + let serial_paths = self.config.serial_paths.clone(); + let board_name = self.config.device_id(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + self.command_shutdown = Some(shutdown_tx); + + self.command_task = Some(tokio::spawn(async move { + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + break; + }; + match command { + BoardCommand::QueryBzm2DtsVs { thread_index, asic, reply } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + let update = handle + .query_dts_vs(asic) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string()))?; + publish_thread_telemetry(&state_tx, &update); + Ok(()) + } + .await; + let _ = reply.send(result); + } + BoardCommand::QueryBzm2Noop { thread_index, asic, reply } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .noop(asic) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::QueryBzm2Loopback { + thread_index, + asic, + payload, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .loopback(asic, payload) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::ReadBzm2Register { + thread_index, + asic, + engine_address, + offset, + count, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .read_register(asic, engine_address, offset, count) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::WriteBzm2Register { + thread_index, + asic, + engine_address, + offset, + value, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + handle + .write_register(asic, engine_address, offset, value) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string())) + } + .await; + let _ = reply.send(result); + } + BoardCommand::DiscoverBzm2Engines { + thread_index, + asic, + tdm_prediv_raw, + tdm_counter, + timeout_ms, + reply, + } => { + let result = async { + let handle = shutdown_handles.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "invalid BZM2 thread index {thread_index} for board {board_name}" + )) + })?; + let serial_path = serial_paths.get(thread_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "missing serial path for BZM2 thread index {thread_index} on board {board_name}" + )) + })?; + let discovery = handle + .discover_engine_map( + asic, + tdm_prediv_raw, + tdm_counter, + Duration::from_millis(u64::from( + timeout_ms.unwrap_or( + DEFAULT_ENGINE_DISCOVERY_TIMEOUT_MS as u32, + ), + )), + ) + .await + .map_err(|err| BoardError::HardwareControl(err.to_string()))?; + publish_discovered_engine_map( + &state_tx, + thread_index, + serial_path, + &discovery, + ); + Ok(()) + } + .await; + let _ = reply.send(result); + } + } + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + })); + } + + async fn resolve_bus_layouts(&self) -> Result, BoardError> { + let configured = build_bus_layouts( + &self.config.serial_paths, + &self.config.calibration.asics_per_bus, + ); + if !self.config.enumeration.enabled { + return Ok(configured); + } + + let discovered = self.enumerate_bus_layouts().await?; + if should_fallback_to_configured_bus_layouts(&discovered, &configured) { + warn!( + board = %self.config.device_id(), + "BZM2 startup enumeration found no ASICs on the default id; falling back to configured bus topology" + ); + return Ok(configured); + } + + Ok(discovered) + } + + async fn enumerate_bus_layouts(&self) -> Result, BoardError> { + let mut counts = Vec::with_capacity(self.config.serial_paths.len()); + + for (index, serial_path) in self.config.serial_paths.iter().enumerate() { + let max_asics = *self + .config + .enumeration + .max_asics_per_bus + .get(index) + .or_else(|| self.config.enumeration.max_asics_per_bus.last()) + .unwrap_or(&DEFAULT_ENUMERATION_MAX_ASICS_PER_BUS); + let max_asics = max_asics.min(u8::MAX as u16) as u8; + + let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 enumeration transport {}: {}", + serial_path, err + )) + })?; + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + let assigned = uart + .enumerate_chain(max_asics, self.config.enumeration.start_id) + .await + .map_err(|err| { + BoardError::InitializationFailed(format!( + "BZM2 startup enumeration failed on {}: {}", + serial_path, err + )) + })?; + counts.push(assigned.len() as u16); + info!( + board = %self.config.device_id(), + serial_path, + asic_count = assigned.len(), + "BZM2 startup enumeration completed" + ); + } + + Ok(build_discovered_bus_layouts( + &self.config.serial_paths, + &counts, + )) + } + + async fn execute_live_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + ) -> Result<(), BoardError> { + let calibration = &self.config.calibration; + if !calibration.enabled { + return Ok(()); + } + + let total_asics = bus_layouts + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + if total_asics == 0 { + return Ok(()); + } + + let loaded_profile = + load_saved_operating_point_profile(calibration.profile_path.as_deref()) + .map_err(BoardError::InitializationFailed)?; + if calibration.apply_saved_operating_point && !calibration.force_retune { + if let Some(profile) = loaded_profile + .as_ref() + .and_then(|loaded| loaded.persisted.as_ref()) + .filter(|profile| profile.is_compatible(calibration, &bus_layouts)) + { + self.apply_saved_operating_point(&bus_layouts, profile) + .await?; + info!( + board = %self.config.device_id(), + asic_count = profile.saved_state.per_asic_pll_mhz.len(), + "BZM2 replayed saved operating point profile" + ); + return Ok(()); + } + } + + let telemetry = self.config.telemetry.snapshot(); + let site_temp_c = calibration + .site_temp_c + .or_else(|| snapshot_temperature(&telemetry, "board")) + .or_else(|| snapshot_temperature(&telemetry, "asic")) + .unwrap_or(DEFAULT_CALIBRATION_SITE_TEMP_C); + let saved_operating_point = loaded_profile + .as_ref() + .map(|loaded| loaded.saved_state.clone()); + let engine_topology = self + .resolve_engine_topology_for_calibration(&bus_layouts, saved_operating_point.as_ref()) + .await; + let (voltage_domains, domain_lookup) = build_voltage_domains( + total_asics as u16, + &calibration.asics_per_domain, + &calibration.domain_voltage_offsets_mv, + ); + let asics = build_topology(&bus_layouts, &domain_lookup, &engine_topology); + let per_asic_throughput = saved_operating_point + .as_ref() + .map(|stored| distribute_saved_throughput(stored.board_throughput_ths, &asics)); + let shared_temp = snapshot_temperature(&telemetry, "asic") + .or_else(|| snapshot_temperature(&telemetry, "board")); + let asic_measurements = asics + .iter() + .map(|asic| Bzm2AsicMeasurement { + asic_id: asic.asic_id, + temperature_c: shared_temp, + throughput_ths: per_asic_throughput + .as_ref() + .and_then(|throughput| throughput.get(&asic.asic_id).copied()), + average_pass_rate: None, + pll_pass_rates: [None, None], + }) + .collect::>(); + let shared_domain_power = snapshot_input_power(&telemetry).map(|power| { + if voltage_domains.is_empty() { + power + } else { + power / voltage_domains.len() as f32 + } + }); + let domain_measurements = voltage_domains + .iter() + .map(|domain| Bzm2DomainMeasurement { + domain_id: domain.domain_id, + measured_voltage_mv: None, + measured_power_w: shared_domain_power, + }) + .collect::>(); + + let planner = Bzm2CalibrationPlanner; + let plan = planner.plan(&Bzm2BoardCalibrationInput { + operating_class: calibration.operating_class, + site_temp_c, + target_mode: calibration.performance_mode, + mode: calibration.mode, + per_stack_clocking: calibration.per_stack_clocking, + voltage_domains: voltage_domains.clone(), + asics: asics.clone(), + saved_operating_point, + domain_measurements, + asic_measurements, + constraints: Bzm2CalibrationConstraints::default(), + force_retune: calibration.force_retune, + }); + let per_domain_voltage_mv = plan + .domain_plans + .iter() + .map(|domain| (domain.domain_id, domain.voltage_mv)) + .collect::>(); + self.apply_domain_voltage_map(&per_domain_voltage_mv) + .await?; + + let per_asic_pll_mhz = plan + .asic_plans + .iter() + .map(|plan| (plan.asic_id, plan.pll_frequencies_mhz)) + .collect::>(); + self.apply_frequency_map( + &bus_layouts, + [plan.initial_frequency_mhz; 2], + &per_asic_pll_mhz, + ) + .await?; + store_applied_operating_state( + &self.applied_operating_state, + &per_domain_voltage_mv, + &per_asic_pll_mhz, + ); + + if let Some(profile_path) = calibration.profile_path.as_deref() { + let saved_operating_point = Bzm2SavedOperatingPoint { + board_voltage_mv: average_u32( + plan.domain_plans.iter().map(|domain| domain.voltage_mv), + ) + .unwrap_or(plan.desired_voltage_mv), + board_throughput_ths: estimate_planned_hashrate( + &plan, + self.config.nominal_hashrate_ths as f32, + &asics, + ), + per_domain_voltage_mv, + per_asic_engine_topology: engine_topology, + per_asic_pll_mhz, + }; + let profile = Bzm2PersistedCalibrationProfile { + schema_version: Bzm2PersistedCalibrationProfile::SCHEMA_VERSION, + operating_class: operating_class_name(calibration.operating_class).into(), + performance_mode: performance_mode_name(calibration.performance_mode).into(), + asics_per_bus: bus_layouts.iter().map(|bus| bus.asic_count).collect(), + pll_post1_divider: calibration.pll_post1_divider, + saved_state: saved_operating_point, + }; + store_calibration_profile(profile_path, &profile) + .map_err(BoardError::InitializationFailed)?; + } + + info!(board = %self.config.device_id(), reuse_saved_operating_point = plan.reuse_saved_operating_point, needs_retune = plan.needs_retune, initial_frequency_mhz = plan.initial_frequency_mhz, asic_count = plan.asic_plans.len(), "BZM2 live calibration completed"); + Ok(()) + } + + async fn resolve_engine_topology_for_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + saved_operating_point: Option<&Bzm2SavedOperatingPoint>, + ) -> BTreeMap { + let mut topology = saved_operating_point + .map(|saved| saved.per_asic_engine_topology.clone()) + .unwrap_or_default(); + + if self.config.calibration.discover_engine_topology { + for (asic_id, discovery) in self + .discover_engine_topology_for_calibration(bus_layouts) + .await + { + topology.insert(asic_id, saved_engine_topology_from_discovery(&discovery)); + } + } + + for (thread_index, bus) in bus_layouts.iter().enumerate() { + for asic_id in bus.asic_start..bus.asic_start + bus.asic_count { + let saved = topology + .entry(asic_id) + .or_insert_with(default_saved_engine_topology) + .clone(); + if let Some(local_asic) = bus.local_asic_id(asic_id) { + publish_saved_engine_topology( + &self.state_tx, + thread_index, + &bus.serial_path, + local_asic, + &saved, + ); + } + } + } + + topology + } + + async fn discover_engine_topology_for_calibration( + &self, + bus_layouts: &[Bzm2BusLayout], + ) -> BTreeMap { + let mut topology = BTreeMap::new(); + + for (thread_index, bus) in bus_layouts.iter().enumerate() { + if bus.asic_count == 0 { + continue; + } + let stream = match SerialStream::new(&bus.serial_path, self.config.baud_rate) { + Ok(stream) => stream, + Err(err) => { + warn!( + board = %self.config.device_id(), + path = %bus.serial_path, + error = %err, + "Failed to open BZM2 calibration discovery transport" + ); + continue; + } + }; + let (reader, writer, _control) = stream.split(); + let mut uart = Bzm2UartController::new(reader, writer); + + for local_asic in 0..bus.asic_count { + let global_asic = bus.asic_start + local_asic; + match uart + .discover_engine_map( + local_asic as u8, + self.config.calibration.engine_discovery_tdm_prediv_raw, + self.config.calibration.engine_discovery_tdm_counter, + self.config.calibration.engine_discovery_timeout, + ) + .await + { + Ok(discovery) => { + publish_discovered_engine_map( + &self.state_tx, + thread_index, + &bus.serial_path, + &discovery, + ); + topology.insert(global_asic, discovery); + } + Err(err) => { + warn!( + board = %self.config.device_id(), + path = %bus.serial_path, + asic = local_asic, + error = %err, + "BZM2 calibration engine discovery failed; falling back to saved or default topology" + ); + } + } + } + } + + topology + } + + async fn apply_saved_operating_point( + &self, + bus_layouts: &[Bzm2BusLayout], + profile: &Bzm2PersistedCalibrationProfile, + ) -> Result<(), BoardError> { + self.apply_domain_voltage_map(&profile.saved_state.per_domain_voltage_mv) + .await?; + for bus in bus_layouts { + if bus.asic_count == 0 { + continue; + } + let initial_frequencies = [0usize, 1usize].map(|pll_index| { + average_f32( + (bus.asic_start..bus.asic_start + bus.asic_count) + .filter_map(|asic_id| profile.saved_state.per_asic_pll_mhz.get(&asic_id)) + .map(|frequencies| frequencies[pll_index]), + ) + .unwrap_or(DEFAULT_CALIBRATION_SITE_TEMP_C) + }); + self.apply_bus_frequency_map( + bus, + initial_frequencies, + &profile.saved_state.per_asic_pll_mhz, + ) + .await?; + } + store_applied_operating_state( + &self.applied_operating_state, + &profile.saved_state.per_domain_voltage_mv, + &profile.saved_state.per_asic_pll_mhz, + ); + Ok(()) + } + + async fn apply_domain_voltage_map( + &self, + per_domain_voltage_mv: &BTreeMap, + ) -> Result<(), BoardError> { + if per_domain_voltage_mv.is_empty() { + return Ok(()); + } + if self.config.bringup.rail_set_paths.is_empty() { + warn!( + board = %self.config.device_id(), + ?per_domain_voltage_mv, + "planner produced per-domain voltages, but no BZM2 rail control path is configured" + ); + return Ok(()); + } + + let mut rail_targets_mv = BTreeMap::::new(); + for (&domain_id, &voltage_mv) in per_domain_voltage_mv { + let rail_index = self + .config + .bringup + .rail_index_for_domain(domain_id) + .ok_or_else(|| { + BoardError::HardwareControl(format!( + "BZM2 domain {domain_id} has no mapped rail index" + )) + })?; + if rail_index >= self.config.bringup.rail_set_paths.len() { + return Err(BoardError::HardwareControl(format!( + "BZM2 domain {domain_id} mapped to rail {rail_index}, but only {} rail set paths are configured", + self.config.bringup.rail_set_paths.len() + ))); + } + match rail_targets_mv.entry(rail_index) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(voltage_mv); + } + std::collections::btree_map::Entry::Occupied(entry) + if *entry.get() != voltage_mv => + { + return Err(BoardError::HardwareControl(format!( + "BZM2 rail {rail_index} received conflicting domain voltages: {}mV vs {}mV", + entry.get(), + voltage_mv + ))); + } + std::collections::btree_map::Entry::Occupied(_) => {} + } + } + + let mut rails = self.config.bringup.build_rails(); + for (rail_index, voltage_mv) in rail_targets_mv { + let rail = rails.get_mut(rail_index).ok_or_else(|| { + BoardError::HardwareControl(format!( + "BZM2 rail {rail_index} is missing from configured rail controls" + )) + })?; + rail.set_voltage(voltage_mv as f32 / 1000.0) + .await + .map_err(|err| { + BoardError::HardwareControl(format!( + "Failed to apply BZM2 domain voltage {voltage_mv}mV on rail {rail_index}: {err}" + )) + })?; + } + Ok(()) + } + + async fn apply_frequency_map( + &self, + bus_layouts: &[Bzm2BusLayout], + initial_frequencies_mhz: [f32; 2], + per_asic_pll_mhz: &BTreeMap, + ) -> Result<(), BoardError> { + for bus in bus_layouts { + self.apply_bus_frequency_map(bus, initial_frequencies_mhz, per_asic_pll_mhz) + .await?; + } + Ok(()) + } + + async fn apply_bus_frequency_map( + &self, + bus: &Bzm2BusLayout, + initial_frequencies_mhz: [f32; 2], + per_asic_pll_mhz: &BTreeMap, + ) -> Result<(), BoardError> { + if bus.asic_count == 0 { + return Ok(()); + } + let stream = SerialStream::new(&bus.serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 calibration transport {}: {}", + bus.serial_path, err + )) + })?; + let (reader, writer, _control) = stream.split(); + let mut clock = Bzm2ClockController::new(reader, writer); + + for (pll, frequency_mhz) in [Bzm2Pll::Pll0, Bzm2Pll::Pll1] + .into_iter() + .zip(initial_frequencies_mhz) + { + clock + .broadcast_pll_frequency( + pll, + frequency_mhz, + self.config.calibration.pll_post1_divider, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + clock + .broadcast_enable_pll(pll) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + + if !self.config.calibration.skip_lock_check { + for local_asic in 0..bus.asic_count { + for pll in [Bzm2Pll::Pll0, Bzm2Pll::Pll1] { + clock + .wait_for_pll_lock( + local_asic as u8, + pll, + self.config.calibration.lock_timeout, + self.config.calibration.lock_poll_interval, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + } + } + + for asic_id in bus.asic_start..bus.asic_start + bus.asic_count { + let Some(frequencies_mhz) = per_asic_pll_mhz.get(&asic_id) else { + continue; + }; + let local_asic = bus + .local_asic_id(asic_id) + .expect("bus layout must contain loop asic id"); + for (index, frequency_mhz) in frequencies_mhz.iter().enumerate() { + let pll = if index == 0 { + Bzm2Pll::Pll0 + } else { + Bzm2Pll::Pll1 + }; + clock + .set_pll_frequency( + local_asic, + pll, + *frequency_mhz, + self.config.calibration.pll_post1_divider, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + clock + .enable_pll(local_asic, pll) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + if !self.config.calibration.skip_lock_check { + clock + .wait_for_pll_lock( + local_asic, + pll, + self.config.calibration.lock_timeout, + self.config.calibration.lock_poll_interval, + ) + .await + .map_err(|err| calibration_error(&bus.serial_path, err))?; + } + } + } + + Ok(()) + } +} + +#[async_trait] +impl Board for Bzm2Board { + fn board_info(&self) -> BoardInfo { + BoardInfo { + model: "BZM2".into(), + firmware_version: None, + serial_number: Some(self.config.device_id()), + } + } + + async fn shutdown(&mut self) -> Result<(), BoardError> { + if let Some(tx) = self.monitor_shutdown.take() { + let _ = tx.send(true); + } + if let Some(tx) = self.command_shutdown.take() { + let _ = tx.send(true); + } + if let Some(handle) = self.monitor_task.take() { + let _ = handle.await; + } + if let Some(handle) = self.command_task.take() { + let _ = handle.await; + } + for handle in &self.shutdown_handles { + handle.shutdown(); + } + self.shutdown_handles.clear(); + self.serial_controls.clear(); + self.command_rx = None; + let _ = self.state_tx.send_modify(|state| { + for thread in &mut state.threads { + thread.is_active = false; + thread.hashrate = 0; + } + }); + self.apply_shutdown_sequence().await?; + Ok(()) + } + + async fn create_hash_threads(&mut self) -> Result>, BoardError> { + let mut threads: Vec> = Vec::new(); + let mut thread_states = Vec::new(); + self.apply_bringup_sequence().await?; + let bus_layouts = self.resolve_bus_layouts().await?; + *self.bus_layouts.lock().unwrap_or_else(|e| e.into_inner()) = bus_layouts.clone(); + let initial_snapshot = self.config.telemetry.snapshot(); + let initial_rail_snapshot = self.config.bringup.snapshot_telemetry(); + let _ = self.state_tx.send_modify(|state| { + state.fans = initial_snapshot.fans.clone(); + merge_temperature_readings(&mut state.temperatures, &initial_snapshot.temperatures); + merge_power_readings(&mut state.powers, &initial_snapshot.powers); + merge_temperature_readings( + &mut state.temperatures, + &initial_rail_snapshot.temperatures, + ); + merge_power_readings(&mut state.powers, &initial_rail_snapshot.powers); + }); + + self.execute_live_calibration(&bus_layouts).await?; + let post_calibration_rail_snapshot = self.config.bringup.snapshot_telemetry(); + let _ = self.state_tx.send_modify(|state| { + merge_temperature_readings( + &mut state.temperatures, + &post_calibration_rail_snapshot.temperatures, + ); + merge_power_readings(&mut state.powers, &post_calibration_rail_snapshot.powers); + }); + + for (index, serial_path) in self.config.serial_paths.iter().enumerate() { + let stream = SerialStream::new(serial_path, self.config.baud_rate).map_err(|err| { + BoardError::InitializationFailed(format!( + "Failed to open BZM2 serial transport {}: {}", + serial_path, err + )) + })?; + let (reader, writer, control) = stream.split(); + let thread_name = format!("BZM2 UART {}", index); + let mut config = Bzm2ThreadConfig::new(serial_path.clone(), self.config.baud_rate); + config.timestamp_count = self.config.timestamp_count; + config.nonce_gap = self.config.nonce_gap; + config.dispatch_interval = self.config.dispatch_interval; + config.nominal_hashrate_ths = self.config.nominal_hashrate_ths; + config.dts_vs_generation = self.config.dts_vs_generation; + + self.serial_controls.push(control.clone()); + let thread = Bzm2Thread::new(thread_name.clone(), reader, writer, control, config); + self.shutdown_handles.push(thread.shutdown_handle()); + thread_states.push(ThreadState { + name: thread_name, + hashrate: 0, + is_active: false, + }); + threads.push(Box::new(Bzm2ManagedThread::new( + Box::new(thread), + self.state_tx.clone(), + index, + ))); + } + + let _ = self.state_tx.send_modify(|state| { + state.threads = thread_states.clone(); + }); + + self.spawn_monitor(); + self.spawn_command_loop(); + Ok(threads) + } +} + +struct Bzm2ManagedThread { + inner: Box, + state_tx: watch::Sender, + thread_index: usize, +} + +impl Bzm2ManagedThread { + fn new( + inner: Box, + state_tx: watch::Sender, + thread_index: usize, + ) -> Self { + Self { + inner, + state_tx, + thread_index, + } + } + + fn publish_status(&self, status: &HashThreadStatus) { + publish_thread_status(&self.state_tx, self.thread_index, status); + } +} + +#[async_trait] +impl HashThread for Bzm2ManagedThread { + fn name(&self) -> &str { + self.inner.name() + } + fn capabilities(&self) -> &HashThreadCapabilities { + self.inner.capabilities() + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.update_task(new_task).await; + self.publish_status(&self.inner.status()); + result + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.replace_task(new_task).await; + self.publish_status(&self.inner.status()); + result + } + + async fn go_idle(&mut self) -> Result, HashThreadError> { + let result = self.inner.go_idle().await; + self.publish_status(&self.inner.status()); + result + } + + fn take_event_receiver(&mut self) -> Option> { + let mut inner_rx = self.inner.take_event_receiver()?; + let (event_tx, event_rx) = mpsc::channel(64); + let state_tx = self.state_tx.clone(); + let thread_index = self.thread_index; + tokio::spawn(async move { + while let Some(event) = inner_rx.recv().await { + match &event { + HashThreadEvent::StatusUpdate(status) => { + publish_thread_status(&state_tx, thread_index, status); + } + HashThreadEvent::TelemetryUpdate(update) => { + publish_thread_telemetry(&state_tx, update); + } + _ => {} + } + if event_tx.send(event).await.is_err() { + break; + } + } + }); + Some(event_rx) + } + + fn status(&self) -> HashThreadStatus { + self.inner.status() + } +} + +fn publish_thread_status( + state_tx: &watch::Sender, + thread_index: usize, + status: &HashThreadStatus, +) { + let _ = state_tx.send_modify(|state| { + if let Some(thread) = state.threads.get_mut(thread_index) { + thread.hashrate = status.hashrate.0; + thread.is_active = status.is_active; + } + }); +} + +fn publish_thread_telemetry( + state_tx: &watch::Sender, + update: &HashThreadTelemetryUpdate, +) { + let _ = state_tx.send_modify(|state| { + merge_temperature_readings( + &mut state.temperatures, + &update + .temperatures + .iter() + .map(|reading| TemperatureSensor { + name: reading.name.clone(), + temperature_c: reading.temperature_c, + }) + .collect::>(), + ); + merge_power_readings( + &mut state.powers, + &update + .powers + .iter() + .map(|reading| PowerMeasurement { + name: reading.name.clone(), + voltage_v: reading.voltage_v, + current_a: reading.current_a, + power_w: reading.power_w, + }) + .collect::>(), + ); + }); +} + +fn publish_discovered_engine_map( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + discovery: &Bzm2DiscoveredEngineMap, +) { + upsert_asic_state( + state_tx, + thread_index, + serial_path, + discovery.asic, + discovery.present_count() as u16, + discovery + .missing + .iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect(), + ); +} + +fn publish_saved_engine_topology( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + asic_id: u8, + topology: &Bzm2SavedEngineTopology, +) { + upsert_asic_state( + state_tx, + thread_index, + serial_path, + asic_id, + topology.active_engine_count, + topology + .missing_engines + .iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect(), + ); +} + +fn upsert_asic_state( + state_tx: &watch::Sender, + thread_index: usize, + serial_path: &str, + asic_id: u8, + active_engine_count: u16, + missing_engines: Vec, +) { + let _ = state_tx.send_modify(|state| { + if let Some(asic) = state + .asics + .iter_mut() + .find(|asic| asic.thread_index == Some(thread_index) && asic.id == asic_id) + { + asic.serial_path = Some(serial_path.to_owned()); + asic.discovered_engine_count = Some(active_engine_count); + asic.missing_engines = missing_engines.clone(); + } else { + state.asics.push(AsicState { + id: asic_id, + thread_index: Some(thread_index), + serial_path: Some(serial_path.to_owned()), + discovered_engine_count: Some(active_engine_count), + missing_engines: missing_engines.clone(), + }); + } + state + .asics + .sort_by_key(|asic| (asic.thread_index.unwrap_or(usize::MAX), asic.id)); + }); +} + +fn merge_temperature_readings( + existing: &mut Vec, + updates: &[TemperatureSensor], +) { + for update in updates { + if let Some(sensor) = existing + .iter_mut() + .find(|sensor| sensor.name == update.name) + { + sensor.temperature_c = update.temperature_c; + } else { + existing.push(update.clone()); + } + } +} + +fn merge_power_readings(existing: &mut Vec, updates: &[PowerMeasurement]) { + for update in updates { + if let Some(sensor) = existing + .iter_mut() + .find(|sensor| sensor.name == update.name) + { + sensor.voltage_v = update.voltage_v; + sensor.current_a = update.current_a; + sensor.power_w = update.power_w; + } else { + existing.push(update.clone()); + } + } +} + +fn build_bus_layouts(serial_paths: &[String], asics_per_bus: &[u16]) -> Vec { + build_bus_layouts_with_minimum(serial_paths, asics_per_bus, 1) +} + +fn build_discovered_bus_layouts( + serial_paths: &[String], + asics_per_bus: &[u16], +) -> Vec { + build_bus_layouts_with_minimum(serial_paths, asics_per_bus, 0) +} + +fn build_bus_layouts_with_minimum( + serial_paths: &[String], + asics_per_bus: &[u16], + minimum_asic_count: u16, +) -> Vec { + let mut next_asic = 0u16; + serial_paths + .iter() + .enumerate() + .map(|(index, path)| { + let asic_count = *asics_per_bus + .get(index) + .or_else(|| asics_per_bus.last()) + .unwrap_or(&1) + .max(&minimum_asic_count); + let layout = Bzm2BusLayout { + serial_path: path.clone(), + asic_start: next_asic, + asic_count, + }; + next_asic = next_asic.saturating_add(asic_count); + layout + }) + .collect() +} + +fn should_fallback_to_configured_bus_layouts( + discovered: &[Bzm2BusLayout], + configured: &[Bzm2BusLayout], +) -> bool { + let discovered_total = discovered + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + let configured_total = configured + .iter() + .map(|layout| layout.asic_count as usize) + .sum::(); + discovered_total == 0 && configured_total > 0 +} + +fn build_voltage_domains( + total_asics: u16, + asics_per_domain: &[u16], + domain_voltage_offsets_mv: &[i32], +) -> (Vec, BTreeMap) { + let mut domains = Vec::new(); + let mut lookup = BTreeMap::new(); + let mut domain_id = 0u16; + let mut asic_start = 0u16; + while asic_start < total_asics { + let requested = *asics_per_domain + .get(domain_id as usize) + .or_else(|| asics_per_domain.last()) + .unwrap_or(&total_asics) + .max(&1); + let asic_end = (asic_start.saturating_add(requested)).min(total_asics); + let asic_ids = (asic_start..asic_end).collect::>(); + for asic_id in &asic_ids { + lookup.insert(*asic_id, domain_id); + } + domains.push(Bzm2VoltageDomain { + domain_id, + asic_ids, + voltage_offset_mv: *domain_voltage_offsets_mv + .get(domain_id as usize) + .or_else(|| domain_voltage_offsets_mv.last()) + .unwrap_or(&0), + max_power_w: None, + }); + domain_id = domain_id.saturating_add(1); + asic_start = asic_end; + } + (domains, lookup) +} + +fn build_topology( + bus_layouts: &[Bzm2BusLayout], + domain_lookup: &BTreeMap, + engine_topology: &BTreeMap, +) -> Vec { + let mut asics = Vec::new(); + for layout in bus_layouts { + for asic_id in layout.asic_start..layout.asic_start + layout.asic_count { + let saved_topology = engine_topology + .get(&asic_id) + .cloned() + .unwrap_or_else(default_saved_engine_topology); + asics.push(Bzm2AsicTopology { + asic_id, + domain_id: *domain_lookup.get(&asic_id).unwrap_or(&0), + pll_count: 2, + alive: true, + active_engine_count: saved_topology.active_engine_count, + missing_engines: saved_topology.missing_engines, + }); + } + } + asics +} + +fn default_saved_engine_topology() -> Bzm2SavedEngineTopology { + Bzm2SavedEngineTopology { + active_engine_count: crate::asic::bzm2::protocol::default_engine_coordinates().len() as u16, + missing_engines: crate::asic::bzm2::protocol::default_excluded_engines() + .into_iter() + .map(|(row, col)| Bzm2SavedEngineCoordinate { row, col }) + .collect(), + } +} + +fn saved_engine_topology_from_discovery( + discovery: &Bzm2DiscoveredEngineMap, +) -> Bzm2SavedEngineTopology { + Bzm2SavedEngineTopology { + active_engine_count: discovery.present_count() as u16, + missing_engines: discovery + .missing + .iter() + .map(|coord| Bzm2SavedEngineCoordinate { + row: coord.row, + col: coord.col, + }) + .collect(), + } +} + +fn distribute_saved_throughput( + total_throughput_ths: f32, + asics: &[Bzm2AsicTopology], +) -> BTreeMap { + let total_active = asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| asic.active_engine_count.max(1) as f32) + .sum::() + .max(1.0); + + asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| { + ( + asic.asic_id, + total_throughput_ths * (asic.active_engine_count.max(1) as f32 / total_active), + ) + }) + .collect() +} + +fn store_applied_operating_state( + state: &Arc>, + per_domain_voltage_mv: &BTreeMap, + per_asic_pll_mhz: &BTreeMap, +) { + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); + guard.per_domain_voltage_mv = per_domain_voltage_mv.clone(); + guard.per_asic_pll_mhz = per_asic_pll_mhz.clone(); +} + +async fn collect_thread_runtime_metrics( + handles: &[Bzm2ThreadHandle], +) -> BTreeMap { + let mut metrics = BTreeMap::new(); + for (thread_index, handle) in handles.iter().enumerate() { + match handle.runtime_metrics().await { + Ok(snapshot) => { + metrics.insert(thread_index, snapshot); + } + Err(err) => { + warn!(thread_index, error = %err, "Failed to query BZM2 runtime metrics"); + } + } + } + metrics +} + +fn build_runtime_tuning_state( + asics: &[AsicState], + temperatures: &[TemperatureSensor], + bus_layouts: &[Bzm2BusLayout], + calibration: &Bzm2CalibrationConfig, + bringup: &Bzm2BringupConfig, + rail_snapshot: &Bzm2TelemetrySnapshot, + applied_operating_state: &Bzm2AppliedOperatingState, + thread_metrics: &BTreeMap, +) -> (Bzm2TuningState, Bzm2RuntimeMeasurementCache) { + let total_asics = bus_layouts.iter().map(|bus| bus.asic_count).sum::(); + let (domains, _domain_lookup) = build_voltage_domains( + total_asics, + &calibration.asics_per_domain, + &calibration.domain_voltage_offsets_mv, + ); + + let mut tuning_domains = Vec::new(); + let mut cache_domains = BTreeMap::new(); + for domain in domains { + let rail_index = bringup.rail_index_for_domain(domain.domain_id); + let rail_output_name = rail_index.map(|index| format!("rail{index}-output")); + let measured_voltage_mv = rail_output_name + .as_ref() + .and_then(|name| { + rail_snapshot + .powers + .iter() + .find(|power| power.name == *name) + }) + .and_then(|power| power.voltage_v) + .map(|voltage| (voltage * 1000.0).round() as u32); + let measured_power_w = rail_output_name + .as_ref() + .and_then(|name| { + rail_snapshot + .powers + .iter() + .find(|power| power.name == *name) + }) + .and_then(|power| power.power_w); + tuning_domains.push(Bzm2DomainTuningState { + domain_id: domain.domain_id, + rail_index, + target_voltage_mv: applied_operating_state + .per_domain_voltage_mv + .get(&domain.domain_id) + .copied(), + measured_voltage_mv, + measured_power_w, + }); + cache_domains.insert( + domain.domain_id, + Bzm2DomainMeasurement { + domain_id: domain.domain_id, + measured_voltage_mv, + measured_power_w, + }, + ); + } + tuning_domains.sort_by_key(|domain| domain.domain_id); + + let mut board_throughput_hs = 0u64; + let mut board_has_throughput = false; + let mut tuning_asics = Vec::new(); + let mut cache_asics = BTreeMap::new(); + + for asic in asics { + let Some(thread_index) = asic.thread_index else { + continue; + }; + let Some(bus) = bus_layouts.get(thread_index) else { + continue; + }; + let Some(global_asic_id) = bus.global_asic_id(asic.id) else { + continue; + }; + let runtime_asic = thread_metrics + .get(&thread_index) + .and_then(|metrics| metrics.asics.iter().find(|metrics| metrics.asic == asic.id)); + let missing_engines = + if asic.missing_engines.is_empty() && asic.discovered_engine_count.is_none() { + default_saved_engine_topology() + .missing_engines + .into_iter() + .map(|engine| EngineCoordinate { + row: engine.row, + col: engine.col, + }) + .collect::>() + } else { + asic.missing_engines.clone() + }; + let (stack0_active, stack1_active) = + split_active_engine_counts(asic.discovered_engine_count, &missing_engines); + let frequencies = applied_operating_state + .per_asic_pll_mhz + .get(&global_asic_id) + .copied(); + let mut pll_states = Vec::with_capacity(2); + let mut pll_pass_rates = [None, None]; + + for pll_index in 0..2usize { + let throughput_hs = runtime_asic.and_then(|asic| asic.plls[pll_index].throughput_hs); + let frequency_mhz = frequencies.map(|freq| freq[pll_index]); + let active_engines = if pll_index == 0 { + stack0_active + } else { + stack1_active + }; + let pass_rate = + throughput_hs + .zip(frequency_mhz) + .and_then(|(throughput_hs, frequency_mhz)| { + expected_stack_throughput_hs(active_engines, frequency_mhz) + .map(|expected| throughput_hs as f32 / expected.max(1) as f32) + }); + pll_pass_rates[pll_index] = pass_rate; + pll_states.push(Bzm2PllTuningState { + pll_index: pll_index as u8, + frequency_mhz, + throughput_hs, + pass_rate, + }); + } + + let average_pass_rate = weighted_average_pass_rate(&[ + (pll_pass_rates[0], stack0_active), + (pll_pass_rates[1], stack1_active), + ]); + let throughput_hs = runtime_asic.and_then(|asic| asic.throughput_hs); + if let Some(throughput_hs) = throughput_hs { + board_throughput_hs = board_throughput_hs.saturating_add(throughput_hs); + board_has_throughput = true; + } + + tuning_asics.push(Bzm2AsicTuningState { + id: asic.id, + thread_index: asic.thread_index, + active_engine_count: asic.discovered_engine_count, + throughput_hs, + average_pass_rate, + scheduler_share_count: runtime_asic.map(|asic| asic.scheduler_share_count), + plls: pll_states, + }); + + cache_asics.insert( + global_asic_id, + Bzm2AsicMeasurement { + asic_id: global_asic_id, + temperature_c: asic_temperature_for_sensor( + temperatures, + bus.serial_path.as_str(), + asic.id, + ), + throughput_ths: throughput_hs + .map(|throughput| throughput as f32 / 1_000_000_000_000.0), + average_pass_rate, + pll_pass_rates, + }, + ); + } + tuning_asics.sort_by_key(|asic| (asic.thread_index.unwrap_or(usize::MAX), asic.id)); + + ( + Bzm2TuningState { + board_throughput_hs: board_has_throughput.then_some(board_throughput_hs), + domains: tuning_domains, + asics: tuning_asics, + }, + Bzm2RuntimeMeasurementCache { + domain_measurements: cache_domains, + asic_measurements: cache_asics, + }, + ) +} + +fn split_active_engine_counts( + active_engine_count: Option, + missing_engines: &[EngineCoordinate], +) -> (u16, u16) { + if missing_engines.is_empty() { + if let Some(active_engine_count) = active_engine_count { + let lower = active_engine_count / 2; + return (lower, active_engine_count.saturating_sub(lower)); + } + } + let mut bottom_missing = 0u16; + let mut top_missing = 0u16; + for engine in missing_engines { + if engine.row < 10 { + bottom_missing = bottom_missing.saturating_add(1); + } else { + top_missing = top_missing.saturating_add(1); + } + } + let engines_per_stack = (10u16 * 12u16) as u16; + ( + engines_per_stack.saturating_sub(bottom_missing), + engines_per_stack.saturating_sub(top_missing), + ) +} + +fn expected_stack_throughput_hs(active_engines: u16, frequency_mhz: f32) -> Option { + (frequency_mhz > 0.0).then(|| { + let ghs = active_engines as f32 * 4.0 * (frequency_mhz / 1000.0) / 3.0; + (ghs * 1_000_000_000.0).round() as u64 + }) +} + +fn weighted_average_pass_rate(samples: &[(Option, u16)]) -> Option { + let mut weighted = 0.0f32; + let mut total_weight = 0u32; + for (pass_rate, weight) in samples { + if let Some(pass_rate) = pass_rate { + weighted += pass_rate * *weight as f32; + total_weight += u32::from(*weight); + } + } + (total_weight > 0).then_some(weighted / total_weight as f32) +} + +fn asic_temperature_for_sensor( + temperatures: &[TemperatureSensor], + serial_path: &str, + asic: u8, +) -> Option { + let prefix = Path::new(serial_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(serial_path) + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + let name = format!("{prefix}-asic-{asic}-dts"); + temperatures + .iter() + .find(|sensor| sensor.name == name) + .and_then(|sensor| sensor.temperature_c) +} + +fn load_saved_operating_point_profile( + path: Option<&Path>, +) -> Result, String> { + let Some(path) = path else { + return Ok(None); + }; + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(path).map_err(|err| { + format!( + "Failed to read calibration profile {}: {}", + path.display(), + err + ) + })?; + + if let Ok(profile) = serde_json::from_str::(&raw) { + return Ok(Some(Bzm2LoadedCalibrationProfile { + saved_state: profile.saved_state.clone(), + persisted: Some(profile), + })); + } + + serde_json::from_str::(&raw) + .map(|saved_state| { + Some(Bzm2LoadedCalibrationProfile { + persisted: None, + saved_state, + }) + }) + .map_err(|err| { + format!( + "Failed to parse calibration profile {}: {}", + path.display(), + err + ) + }) +} + +fn store_calibration_profile( + path: &Path, + profile: &Bzm2PersistedCalibrationProfile, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "Failed to create calibration profile directory {}: {}", + parent.display(), + err + ) + })?; + } + let raw = serde_json::to_string_pretty(profile) + .map_err(|err| format!("Failed to serialize calibration profile: {}", err))?; + fs::write(path, raw).map_err(|err| { + format!( + "Failed to write calibration profile {}: {}", + path.display(), + err + ) + }) +} + +fn estimate_planned_hashrate( + plan: &crate::asic::bzm2::Bzm2CalibrationPlan, + nominal_hashrate_ths: f32, + asics: &[Bzm2AsicTopology], +) -> f32 { + let nominal_board_hashrate = + nominal_hashrate_ths * asics.iter().filter(|asic| asic.alive).count().max(1) as f32; + let average_frequency_mhz = if plan.asic_plans.is_empty() { + plan.desired_clock_mhz + } else { + plan.asic_plans + .iter() + .map(|asic| (asic.pll_frequencies_mhz[0] + asic.pll_frequencies_mhz[1]) / 2.0) + .sum::() + / plan.asic_plans.len() as f32 + }; + let ratio = if plan.desired_clock_mhz > 0.0 { + average_frequency_mhz / plan.desired_clock_mhz + } else { + 1.0 + }; + let active_engine_ratio = { + let total_active = asics + .iter() + .filter(|asic| asic.alive) + .map(|asic| asic.active_engine_count.max(1) as f32) + .sum::() + .max(1.0); + let total_nominal = asics.iter().filter(|asic| asic.alive).count().max(1) as f32 + * default_saved_engine_topology().active_engine_count as f32; + (total_active / total_nominal).max(0.1) + }; + nominal_board_hashrate * ratio.max(0.1) * active_engine_ratio +} + +fn snapshot_temperature(snapshot: &Bzm2TelemetrySnapshot, name: &str) -> Option { + snapshot + .temperatures + .iter() + .find(|sensor| sensor.name == name) + .and_then(|sensor| sensor.temperature_c) +} + +fn snapshot_input_power(snapshot: &Bzm2TelemetrySnapshot) -> Option { + snapshot + .powers + .iter() + .find(|power| power.name == "input") + .and_then(|power| power.power_w) +} + +fn parse_scaled_sensor_value(raw: &str, scale: f32) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + trimmed.parse::().ok().map(|value| value * scale) +} + +fn env_var_any(keys: &[&str]) -> Option { + keys.iter().find_map(|key| env::var(key).ok()) +} + +fn parse_u8_any_radix(raw: &str) -> Option { + parse_u64_any_radix(raw).and_then(|value| u8::try_from(value).ok()) +} + +fn parse_u32_any_radix(raw: &str) -> Option { + parse_u64_any_radix(raw).and_then(|value| u32::try_from(value).ok()) +} + +fn parse_u64_any_radix(raw: &str) -> Option { + let trimmed = raw.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).ok() + } else { + trimmed.parse::().ok() + } +} + +fn env_csv_strings_any(keys: &[&str]) -> Vec { + env_var_any(keys) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn env_flag(key: &str) -> bool { + env_var_any(&[key]).as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn env_flag_any(keys: &[&str]) -> bool { + env_var_any(keys).as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn env_flag_default_any(keys: &[&str], default: bool) -> bool { + env_var_any(keys) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(default) +} + +fn env_f32(key: &str) -> Option { + env_f32_any(&[key]) +} + +fn env_f32_any(keys: &[&str]) -> Option { + env_var_any(keys).and_then(|value| value.parse().ok()) +} + +fn parse_u32(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(hex) = trimmed.strip_prefix("0x") { + u32::from_str_radix(hex, 16).ok() + } else { + trimmed.parse().ok() + } +} + +fn parse_csv_numbers(key: &str) -> Option> +where + T: std::str::FromStr, +{ + let value = env::var(key).ok()?; + let parsed = value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.parse().ok()) + .collect::>>()?; + Some(parsed) +} + +fn parse_csv_numbers_any(keys: &[&str]) -> Option> +where + T: std::str::FromStr, +{ + keys.iter().find_map(|key| parse_csv_numbers::(key)) +} + +fn sensor_specs_from_env( + paths_keys: &[&str], + scales_keys: &[&str], + default_scale: f32, +) -> Vec { + let paths = env_csv_strings_any(paths_keys); + let scales = parse_csv_numbers_any::(scales_keys).unwrap_or_default(); + paths + .into_iter() + .enumerate() + .map(|(index, path)| SensorSpec { + path, + scale: *scales + .get(index) + .or_else(|| scales.last()) + .unwrap_or(&default_scale), + }) + .collect() +} + +fn parse_operating_class(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "generic" => Some(Bzm2OperatingClass::Generic), + "early-validation" | "early_validation" | "dvt1" => { + Some(Bzm2OperatingClass::EarlyValidation) + } + "production-validation" | "production_validation" | "pvt" => { + Some(Bzm2OperatingClass::ProductionValidation) + } + "stack-tuned-a" | "stack_tuned_a" | "dvt2-bin1" | "dvt2_bin1" | "dvt2bin1" | "bin1" => { + Some(Bzm2OperatingClass::StackTunedA) + } + "stack-tuned-b" | "stack_tuned_b" | "dvt2-bin2" | "dvt2_bin2" | "dvt2bin2" | "bin2" => { + Some(Bzm2OperatingClass::StackTunedB) + } + "extended-headroom" | "extended_headroom" | "plus" => { + Some(Bzm2OperatingClass::ExtendedHeadroom) + } + "extended-headroom-b" + | "extended_headroom_b" + | "plus-ebin2" + | "plus_ebin2" + | "plusebin2" + | "ebin2" => Some(Bzm2OperatingClass::ExtendedHeadroomB), + _ => None, + } +} + +fn parse_performance_mode(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "max-throughput" | "max_throughput" | "high" | "high-performance" | "high_performance" + | "performance" => Some(Bzm2PerformanceMode::MaxThroughput), + "standard" | "balanced" => Some(Bzm2PerformanceMode::Standard), + "efficiency" | "low" | "low-power" | "low_power" => Some(Bzm2PerformanceMode::Efficiency), + _ => None, + } +} + +fn average_u32(values: impl Iterator) -> Option { + let mut total = 0u64; + let mut count = 0u64; + for value in values { + total += value as u64; + count += 1; + } + (count > 0).then_some((total / count) as u32) +} + +fn average_f32(values: impl Iterator) -> Option { + let mut total = 0.0f32; + let mut count = 0usize; + for value in values { + total += value; + count += 1; + } + (count > 0).then_some(total / count as f32) +} + +fn operating_class_name(operating_class: Bzm2OperatingClass) -> &'static str { + match operating_class { + Bzm2OperatingClass::Generic => "generic", + Bzm2OperatingClass::EarlyValidation => "early-validation", + Bzm2OperatingClass::ProductionValidation => "production-validation", + Bzm2OperatingClass::StackTunedA => "stack-tuned-a", + Bzm2OperatingClass::StackTunedB => "stack-tuned-b", + Bzm2OperatingClass::ExtendedHeadroom => "extended-headroom", + Bzm2OperatingClass::ExtendedHeadroomB => "extended-headroom-b", + } +} + +fn performance_mode_name(performance_mode: Bzm2PerformanceMode) -> &'static str { + match performance_mode { + Bzm2PerformanceMode::MaxThroughput => "max-throughput", + Bzm2PerformanceMode::Standard => "standard", + Bzm2PerformanceMode::Efficiency => "efficiency", + } +} + +fn calibration_error(serial_path: &str, err: impl std::fmt::Display) -> BoardError { + BoardError::InitializationFailed(format!( + "BZM2 calibration failed on {}: {}", + serial_path, err + )) +} + +async fn create_bzm2_board() +-> crate::error::Result<(Box, super::BoardRegistration)> { + let config = Bzm2VirtualDeviceConfig::from_env().ok_or_else(|| { + crate::error::Error::Config("BZM2 not configured (MUJINA_BZM2_SERIAL not set)".into()) + })?; + + let serial = config.device_id(); + let initial_state = BoardState { + name: serial.clone(), + model: "BZM2".into(), + serial: Some(serial), + ..Default::default() + }; + let (state_tx, state_rx) = watch::channel(initial_state); + let (command_tx, command_rx) = mpsc::channel(16); + + let board = Bzm2Board::new(config, state_tx, command_rx); + let registration = super::BoardRegistration { + state_rx, + command_tx: Some(command_tx), + }; + Ok((Box::new(board), registration)) +} + +inventory::submit! { + VirtualBoardDescriptor { + device_type: "bzm2", + name: "BZM2", + create_fn: || Box::pin(create_bzm2_board()), + } +} +#[cfg(test)] +mod tests { + use super::*; + use crate::asic::bzm2::protocol::{OPCODE_UART_NOOP, encode_noop, encode_write_register}; + use nix::pty::openpty; + use std::fs; + use std::io::{Read, Write}; + use std::os::fd::AsRawFd; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn spawn_chain_emulator( + master: std::os::fd::OwnedFd, + chain_len: u8, + start_id: u8, + ) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut file = fs::File::from(master); + for offset in 0..chain_len { + let mut noop_request = + vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut noop_request).unwrap(); + assert_eq!( + noop_request, + encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID) + ); + file.write_all(&[ + crate::asic::bzm2::DEFAULT_ASIC_ID, + OPCODE_UART_NOOP, + b'B', + b'Z', + b'2', + ]) + .unwrap(); + + let assigned = start_id.saturating_add(offset); + let expected_write = encode_write_register( + crate::asic::bzm2::DEFAULT_ASIC_ID, + crate::asic::bzm2::NOTCH_REG, + 0x0b, + &(assigned as u32).to_le_bytes(), + ); + let mut write_request = vec![0u8; expected_write.len()]; + file.read_exact(&mut write_request).unwrap(); + assert_eq!(write_request, expected_write); + + let mut assigned_noop = vec![0u8; encode_noop(assigned).len()]; + file.read_exact(&mut assigned_noop).unwrap(); + assert_eq!(assigned_noop, encode_noop(assigned)); + file.write_all(&[assigned, OPCODE_UART_NOOP, b'B', b'Z', b'2']) + .unwrap(); + } + + let mut final_probe = vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut final_probe).unwrap(); + assert_eq!(final_probe, encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID)); + }) + } + + #[tokio::test] + async fn live_calibration_persists_profile() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let profile_path = std::env::temp_dir().join(format!( + "bzm2-profile-{}-{}.json", + std::process::id(), + unique + )); + let rail0_path = std::env::temp_dir().join(format!("bzm2-domain-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-domain-rail1-{unique}.txt")); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + ..Default::default() + }, + calibration: Bzm2CalibrationConfig { + enabled: true, + asics_per_bus: vec![2], + asics_per_domain: vec![1], + domain_voltage_offsets_mv: vec![0, 100], + profile_path: Some(profile_path.clone()), + skip_lock_check: true, + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + let bus_layouts = board.resolve_bus_layouts().await.unwrap(); + + board.execute_live_calibration(&bus_layouts).await.unwrap(); + + let profile = load_saved_operating_point_profile(Some(&profile_path)) + .unwrap() + .unwrap(); + assert_eq!(profile.saved_state.per_asic_pll_mhz.len(), 2); + assert_eq!(profile.saved_state.per_domain_voltage_mv.len(), 2); + assert_eq!(profile.saved_state.per_asic_engine_topology.len(), 2); + assert_eq!( + profile + .saved_state + .per_asic_engine_topology + .get(&0) + .unwrap() + .active_engine_count, + default_saved_engine_topology().active_engine_count + ); + assert_eq!( + fs::read_to_string(&rail0_path).unwrap().trim(), + profile + .saved_state + .per_domain_voltage_mv + .get(&0) + .unwrap() + .to_string() + ); + assert_eq!( + fs::read_to_string(&rail1_path).unwrap().trim(), + profile + .saved_state + .per_domain_voltage_mv + .get(&1) + .unwrap() + .to_string() + ); + assert!(profile.persisted.is_some()); + + let _ = fs::remove_file(profile_path); + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); + drop(pty); + } + + #[tokio::test] + async fn stored_profile_replays_on_restart_without_rewrite() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let profile_path = std::env::temp_dir().join(format!( + "bzm2-replay-{}-{}.json", + std::process::id(), + unique + )); + let rail0_path = std::env::temp_dir().join(format!("bzm2-replay-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-replay-rail1-{unique}.txt")); + let persisted = Bzm2PersistedCalibrationProfile { + schema_version: Bzm2PersistedCalibrationProfile::SCHEMA_VERSION, + operating_class: operating_class_name(Bzm2OperatingClass::Generic).into(), + performance_mode: performance_mode_name(Bzm2PerformanceMode::Standard).into(), + asics_per_bus: vec![2], + pll_post1_divider: DEFAULT_CALIBRATION_POST1_DIVIDER, + saved_state: Bzm2SavedOperatingPoint { + board_voltage_mv: 17_500, + board_throughput_ths: 80.0, + per_domain_voltage_mv: BTreeMap::from([(0, 17_450), (1, 17_600)]), + per_asic_engine_topology: BTreeMap::new(), + per_asic_pll_mhz: BTreeMap::from([ + (0, [1_100.0, 1_125.0]), + (1, [1_150.0, 1_175.0]), + ]), + }, + }; + let original = serde_json::to_string_pretty(&persisted).unwrap(); + fs::write(&profile_path, &original).unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + ..Default::default() + }, + calibration: Bzm2CalibrationConfig { + enabled: true, + apply_saved_operating_point: true, + asics_per_bus: vec![2], + profile_path: Some(profile_path.clone()), + skip_lock_check: true, + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + let bus_layouts = board.resolve_bus_layouts().await.unwrap(); + + board.execute_live_calibration(&bus_layouts).await.unwrap(); + + assert_eq!(fs::read_to_string(&profile_path).unwrap(), original); + assert_eq!(fs::read_to_string(&rail0_path).unwrap().trim(), "17450"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap().trim(), "17600"); + + let _ = fs::remove_file(profile_path); + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); + drop(pty); + } + + #[test] + fn build_bus_layouts_assigns_global_ranges() { + let layouts = build_bus_layouts(&["/dev/ttyUSB0".into(), "/dev/ttyUSB1".into()], &[4, 6]); + assert_eq!(layouts[0].asic_start, 0); + assert_eq!(layouts[0].asic_count, 4); + assert_eq!(layouts[1].asic_start, 4); + assert_eq!(layouts[1].asic_count, 6); + } + + #[tokio::test] + async fn resolve_bus_layouts_uses_startup_enumeration_counts() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let emulator = spawn_chain_emulator(master, 2, 0); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig { + enabled: true, + start_id: 0, + max_asics_per_bus: vec![4], + }, + bringup: Bzm2BringupConfig::default(), + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let layouts = board.resolve_bus_layouts().await.unwrap(); + assert_eq!(layouts.len(), 1); + assert_eq!(layouts[0].asic_count, 2); + + emulator.join().unwrap(); + } + + #[tokio::test] + async fn resolve_bus_layouts_falls_back_to_configured_counts_when_default_id_is_silent() { + let pty = openpty(None, None).unwrap(); + let master = pty.master; + let slave = pty.slave; + let serial_path = fs::read_link(format!("/proc/self/fd/{}", slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let emulator = std::thread::spawn(move || { + let mut file = fs::File::from(master); + let mut probe = vec![0u8; encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID).len()]; + file.read_exact(&mut probe).unwrap(); + assert_eq!(probe, encode_noop(crate::asic::bzm2::DEFAULT_ASIC_ID)); + file.write_all(&[ + crate::asic::bzm2::DEFAULT_ASIC_ID, + OPCODE_UART_NOOP, + b'N', + b'O', + b'P', + ]) + .unwrap(); + }); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig { + enabled: true, + start_id: 0, + max_asics_per_bus: vec![4], + }, + bringup: Bzm2BringupConfig::default(), + calibration: Bzm2CalibrationConfig { + asics_per_bus: vec![3], + ..Default::default() + }, + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let layouts = board.resolve_bus_layouts().await.unwrap(); + assert_eq!(layouts.len(), 1); + assert_eq!(layouts[0].asic_count, 3); + + emulator.join().unwrap(); + } + + #[tokio::test] + async fn create_hash_threads_applies_bringup_and_shutdown_sequences() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let rail0_path = std::env::temp_dir().join(format!("bzm2-rail0-{unique}.txt")); + let rail1_path = std::env::temp_dir().join(format!("bzm2-rail1-{unique}.txt")); + let enable0_path = std::env::temp_dir().join(format!("bzm2-enable0-{unique}.txt")); + let enable1_path = std::env::temp_dir().join(format!("bzm2-enable1-{unique}.txt")); + let reset_path = std::env::temp_dir().join(format!("bzm2-reset-{unique}.txt")); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + enabled: true, + rail_set_paths: vec![ + rail0_path.to_string_lossy().into_owned(), + rail1_path.to_string_lossy().into_owned(), + ], + rail_write_scales: vec![1000.0, 1000.0], + domain_rail_indices: Vec::new(), + rail_enable_paths: vec![ + enable0_path.to_string_lossy().into_owned(), + enable1_path.to_string_lossy().into_owned(), + ], + rail_enable_values: vec!["EN".into(), "ON".into()], + rail_vin: Vec::new(), + rail_vout: Vec::new(), + rail_current: Vec::new(), + rail_power: Vec::new(), + rail_temperature: Vec::new(), + reset_path: Some(reset_path.to_string_lossy().into_owned()), + reset_active_low: true, + plan: Bzm2BringupPlan { + pre_power_delay: Duration::ZERO, + post_power_delay: Duration::ZERO, + release_reset_delay: Duration::ZERO, + steps: vec![ + VoltageStackStep { + rail_index: 0, + voltage: 1.1, + settle_for: Duration::ZERO, + }, + VoltageStackStep { + rail_index: 1, + voltage: 1.25, + settle_for: Duration::ZERO, + }, + ], + ..Default::default() + }, + }, + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, _state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let _threads = board.create_hash_threads().await.unwrap(); + + assert_eq!(fs::read_to_string(&rail0_path).unwrap(), "1100"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap(), "1250"); + assert_eq!(fs::read_to_string(&enable0_path).unwrap(), "EN"); + assert_eq!(fs::read_to_string(&enable1_path).unwrap(), "ON"); + assert_eq!(fs::read_to_string(&reset_path).unwrap(), "1"); + + board.shutdown().await.unwrap(); + + assert_eq!(fs::read_to_string(&rail0_path).unwrap(), "0"); + assert_eq!(fs::read_to_string(&rail1_path).unwrap(), "0"); + assert_eq!(fs::read_to_string(&reset_path).unwrap(), "0"); + + let _ = fs::remove_file(rail0_path); + let _ = fs::remove_file(rail1_path); + let _ = fs::remove_file(enable0_path); + let _ = fs::remove_file(enable1_path); + let _ = fs::remove_file(reset_path); + drop(pty); + } + + #[tokio::test] + async fn create_hash_threads_publishes_rail_telemetry() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let vin_path = std::env::temp_dir().join(format!("bzm2-vin-{unique}.txt")); + let vout_path = std::env::temp_dir().join(format!("bzm2-vout-{unique}.txt")); + let current_path = std::env::temp_dir().join(format!("bzm2-current-{unique}.txt")); + let power_path = std::env::temp_dir().join(format!("bzm2-power-{unique}.txt")); + let temp_path = std::env::temp_dir().join(format!("bzm2-temp-{unique}.txt")); + fs::write(&vin_path, "12000\n").unwrap(); + fs::write(&vout_path, "850\n").unwrap(); + fs::write(¤t_path, "1500\n").unwrap(); + fs::write(&power_path, "1275\n").unwrap(); + fs::write(&temp_path, "47000\n").unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig::default(), + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig { + rail_vin: vec![SensorSpec { + path: vin_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_vout: vec![SensorSpec { + path: vout_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_current: vec![SensorSpec { + path: current_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_power: vec![SensorSpec { + path: power_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + rail_temperature: vec![SensorSpec { + path: temp_path.to_string_lossy().into_owned(), + scale: 0.001, + }], + ..Default::default() + }, + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let _threads = board.create_hash_threads().await.unwrap(); + let state = state_rx.borrow().clone(); + assert!(state.temperatures.iter().any(|sensor| { + sensor.name == "rail0-regulator" + && sensor + .temperature_c + .is_some_and(|value| (value - 47.0).abs() < 0.001) + })); + assert!(state.powers.iter().any(|power| { + power.name == "rail0-input" + && power + .voltage_v + .is_some_and(|value| (value - 12.0).abs() < 0.001) + })); + assert!(state.powers.iter().any(|power| { + power.name == "rail0-output" + && power + .voltage_v + .is_some_and(|value| (value - 0.85).abs() < 0.001) + && power + .current_a + .is_some_and(|value| (value - 1.5).abs() < 0.001) + && power + .power_w + .is_some_and(|value| (value - 1.275).abs() < 0.001) + })); + + board.shutdown().await.unwrap(); + + let _ = fs::remove_file(vin_path); + let _ = fs::remove_file(vout_path); + let _ = fs::remove_file(current_path); + let _ = fs::remove_file(power_path); + let _ = fs::remove_file(temp_path); + drop(pty); + } + + #[tokio::test] + async fn board_safety_trip_closes_scheduler_event_stream() { + let pty = openpty(None, None).unwrap(); + let serial_path = fs::read_link(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .unwrap() + .to_string_lossy() + .into_owned(); + + let sensor_path = std::env::temp_dir().join(format!( + "bzm2-trip-{}-{}.txt", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&sensor_path, "90\n").unwrap(); + + let config = Bzm2VirtualDeviceConfig { + serial_paths: vec![serial_path], + baud_rate: DEFAULT_BAUD_RATE, + timestamp_count: crate::asic::bzm2::protocol::DEFAULT_TIMESTAMP_COUNT, + nonce_gap: crate::asic::bzm2::protocol::DEFAULT_NONCE_GAP, + dispatch_interval: Duration::from_millis(50), + nominal_hashrate_ths: DEFAULT_NOMINAL_HASHRATE_THS, + dts_vs_generation: crate::asic::bzm2::protocol::DtsVsGeneration::Gen2, + telemetry: Bzm2TelemetryConfig { + poll_interval: Duration::from_millis(20), + asic_temp: Some(SensorSpec { + path: sensor_path.to_string_lossy().into_owned(), + scale: 1.0, + }), + max_asic_temp_c: Some(80.0), + ..Default::default() + }, + enumeration: Bzm2EnumerationConfig::default(), + bringup: Bzm2BringupConfig::default(), + calibration: Bzm2CalibrationConfig::default(), + }; + let (state_tx, mut state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + let mut board = Bzm2Board::new(config, state_tx, mpsc::channel(1).1); + + let mut threads = board.create_hash_threads().await.unwrap(); + let mut event_rx = threads[0].take_event_receiver().unwrap(); + + let closed = tokio::time::timeout(Duration::from_secs(1), async { + while event_rx.recv().await.is_some() {} + }) + .await; + assert!( + closed.is_ok(), + "event stream should close after safety trip" + ); + + let state = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let snapshot = state_rx.borrow().clone(); + if snapshot + .temperatures + .iter() + .any(|sensor| sensor.name == "asic" && sensor.temperature_c == Some(90.0)) + { + break snapshot; + } + state_rx.changed().await.unwrap(); + } + }) + .await + .unwrap(); + assert_eq!(state.threads[0].hashrate, 0); + + board.shutdown().await.unwrap(); + let _ = fs::remove_file(sensor_path); + drop(pty); + } + + #[test] + fn parse_scaled_sensor_value_applies_scale() { + let parsed = parse_scaled_sensor_value("42500\n", 0.001).unwrap(); + assert!((parsed - 42.5).abs() < 0.001); + assert_eq!(parse_scaled_sensor_value("", 0.001), None); + assert_eq!(parse_scaled_sensor_value("nope", 1.0), None); + } + + #[test] + fn telemetry_trip_detects_thresholds() { + let telemetry = Bzm2TelemetryConfig { + max_asic_temp_c: Some(80.0), + max_input_power_w: Some(1200.0), + ..Default::default() + }; + assert!( + telemetry + .trip_reason(Some(81.0), None, None) + .unwrap() + .contains("ASIC temperature") + ); + assert!(telemetry.trip_reason(None, None, Some(1250.0)).is_some()); + assert!( + telemetry + .trip_reason(Some(75.0), None, Some(1100.0)) + .is_none() + ); + } + + #[test] + fn publish_thread_telemetry_updates_board_state() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + temperatures: vec![TemperatureSensor { + name: "host-board-temp".into(), + temperature_c: Some(52.0), + }], + powers: vec![PowerMeasurement { + name: "host-input".into(), + voltage_v: Some(12.0), + current_a: Some(10.0), + power_w: Some(120.0), + }], + ..Default::default() + }); + + publish_thread_telemetry( + &state_tx, + &HashThreadTelemetryUpdate { + temperatures: vec![crate::asic::hash_thread::HashThreadTemperatureReading { + name: "ttyUSB0-asic-2-dts".into(), + temperature_c: Some(64.5), + }], + powers: vec![crate::asic::hash_thread::HashThreadPowerReading { + name: "ttyUSB0-asic-2-vs-ch0".into(), + voltage_v: Some(0.78), + current_a: None, + power_w: None, + }], + }, + ); + + let state = state_rx.borrow().clone(); + assert_eq!(state.temperatures.len(), 2); + assert!( + state.temperatures.iter().any( + |sensor| sensor.name == "host-board-temp" && sensor.temperature_c == Some(52.0) + ) + ); + assert!(state.temperatures.iter().any( + |sensor| sensor.name == "ttyUSB0-asic-2-dts" && sensor.temperature_c == Some(64.5) + )); + assert_eq!(state.powers.len(), 2); + assert!( + state + .powers + .iter() + .any(|sensor| sensor.name == "host-input" && sensor.voltage_v == Some(12.0)) + ); + assert!( + state + .powers + .iter() + .any(|sensor| sensor.name == "ttyUSB0-asic-2-vs-ch0" + && sensor.voltage_v == Some(0.78)) + ); + } + + #[test] + fn publish_thread_status_updates_state_slot() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + threads: vec![ThreadState { + name: "BZM2 UART 0".into(), + hashrate: 0, + is_active: false, + }], + ..Default::default() + }); + + let status = HashThreadStatus { + hashrate: crate::types::HashRate::from_terahashes(42.0), + is_active: true, + ..Default::default() + }; + + publish_thread_status(&state_tx, 0, &status); + + let state = state_rx.borrow().clone(); + assert_eq!( + state.threads[0].hashrate, + crate::types::HashRate::from_terahashes(42.0).0 + ); + assert!(state.threads[0].is_active); + } + + #[test] + fn publish_discovered_engine_map_updates_board_state() { + let (state_tx, state_rx) = watch::channel(BoardState { + name: "bzm2-test".into(), + model: "BZM2".into(), + serial: Some("bzm2-test".into()), + ..Default::default() + }); + + publish_discovered_engine_map( + &state_tx, + 1, + "/dev/ttyUSB1", + &Bzm2DiscoveredEngineMap { + asic: 2, + present: vec![ + crate::asic::bzm2::Bzm2EngineCoordinate::new(0, 0), + crate::asic::bzm2::Bzm2EngineCoordinate::new(0, 1), + ], + missing: vec![ + crate::asic::bzm2::Bzm2EngineCoordinate::new(3, 7), + crate::asic::bzm2::Bzm2EngineCoordinate::new(5, 11), + ], + }, + ); + + let state = state_rx.borrow().clone(); + assert_eq!(state.asics.len(), 1); + assert_eq!(state.asics[0].id, 2); + assert_eq!(state.asics[0].thread_index, Some(1)); + assert_eq!(state.asics[0].serial_path.as_deref(), Some("/dev/ttyUSB1")); + assert_eq!(state.asics[0].discovered_engine_count, Some(2)); + assert_eq!( + state.asics[0].missing_engines, + vec![ + EngineCoordinate { row: 3, col: 7 }, + EngineCoordinate { row: 5, col: 11 }, + ] + ); + } + + #[test] + fn build_runtime_tuning_state_maps_live_measurements() { + let asics = vec![AsicState { + id: 0, + thread_index: Some(0), + serial_path: Some("/dev/ttyUSB0".into()), + discovered_engine_count: Some(236), + missing_engines: Vec::new(), + }]; + let temperatures = vec![TemperatureSensor { + name: "ttyUSB0-asic-0-dts".into(), + temperature_c: Some(67.0), + }]; + let bus_layouts = vec![Bzm2BusLayout { + serial_path: "/dev/ttyUSB0".into(), + asic_start: 0, + asic_count: 1, + }]; + let calibration = Bzm2CalibrationConfig::default(); + let bringup = Bzm2BringupConfig { + rail_set_paths: vec!["/tmp/rail0".into()], + ..Default::default() + }; + let rail_snapshot = Bzm2TelemetrySnapshot { + powers: vec![PowerMeasurement { + name: "rail0-output".into(), + voltage_v: Some(0.9), + current_a: Some(44.0), + power_w: Some(40.0), + }], + ..Default::default() + }; + let applied = Bzm2AppliedOperatingState { + per_domain_voltage_mv: BTreeMap::from([(0, 18_500)]), + per_asic_pll_mhz: BTreeMap::from([(0, [1_200.0, 1_200.0])]), + }; + let thread_metrics = BTreeMap::from([( + 0usize, + Bzm2ThreadRuntimeMetrics { + throughput_hs: Some(358_720_000_000), + asics: vec![crate::asic::bzm2::Bzm2AsicRuntimeMetrics { + asic: 0, + throughput_hs: Some(358_720_000_000), + scheduler_share_count: 12, + plls: [ + crate::asic::bzm2::Bzm2PllRuntimeMetrics { + throughput_hs: Some(179_360_000_000), + scheduler_share_count: 6, + }, + crate::asic::bzm2::Bzm2PllRuntimeMetrics { + throughput_hs: Some(179_360_000_000), + scheduler_share_count: 6, + }, + ], + }], + }, + )]); + + let (tuning, cache) = build_runtime_tuning_state( + &asics, + &temperatures, + &bus_layouts, + &calibration, + &bringup, + &rail_snapshot, + &applied, + &thread_metrics, + ); + + assert_eq!(tuning.board_throughput_hs, Some(358_720_000_000)); + assert_eq!(tuning.domains.len(), 1); + assert_eq!(tuning.domains[0].target_voltage_mv, Some(18_500)); + assert_eq!(tuning.domains[0].measured_voltage_mv, Some(900)); + assert_eq!(tuning.domains[0].measured_power_w, Some(40.0)); + assert_eq!(tuning.asics.len(), 1); + assert_eq!(tuning.asics[0].throughput_hs, Some(358_720_000_000)); + assert_eq!(tuning.asics[0].scheduler_share_count, Some(12)); + assert!( + tuning.asics[0] + .average_pass_rate + .is_some_and(|pass_rate| (pass_rate - 0.95).abs() < 0.0001) + ); + assert!( + tuning.asics[0].plls[0] + .pass_rate + .is_some_and(|pass_rate| (pass_rate - 0.95).abs() < 0.0001) + ); + assert!( + cache.asic_measurements[&0] + .temperature_c + .is_some_and(|temp| (temp - 67.0).abs() < 0.0001) + ); + assert!( + cache.asic_measurements[&0] + .throughput_ths + .is_some_and(|throughput| (throughput - 0.35872).abs() < 0.0001) + ); + assert_eq!(cache.domain_measurements[&0].measured_voltage_mv, Some(900)); + assert_eq!(cache.domain_measurements[&0].measured_power_w, Some(40.0)); + } +} diff --git a/mujina-miner/src/board/cpu.rs b/mujina-miner/src/board/cpu.rs index b27b38e6..36634443 100644 --- a/mujina-miner/src/board/cpu.rs +++ b/mujina-miner/src/board/cpu.rs @@ -96,7 +96,10 @@ async fn create_cpu_board() let (state_tx, state_rx) = watch::channel(initial_state); let board = CpuBoard::new(config, state_tx); - let registration = super::BoardRegistration { state_rx }; + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/emberone.rs b/mujina-miner/src/board/emberone.rs index 125fd2e4..df9502a3 100644 --- a/mujina-miner/src/board/emberone.rs +++ b/mujina-miner/src/board/emberone.rs @@ -77,7 +77,10 @@ async fn create_from_usb( let board = EmberOne::new(device, state_tx) .map_err(|e| Error::Hardware(format!("Failed to create board: {}", e)))?; - let registration = super::BoardRegistration { state_rx }; + let registration = super::BoardRegistration { + state_rx, + command_tx: None, + }; Ok((Box::new(board), registration)) } diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index d8ce752e..082193f0 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -1,11 +1,12 @@ pub(crate) mod bitaxe; +pub(crate) mod bzm2; pub mod cpu; pub(crate) mod emberone; pub mod pattern; use async_trait::async_trait; use std::{error::Error, fmt, future::Future, pin::Pin}; -use tokio::sync::watch; +use tokio::sync::{mpsc, oneshot, watch}; use crate::{ api_client::types::BoardState, asic::hash_thread::HashThread, transport::UsbDeviceInfo, @@ -88,6 +89,54 @@ impl From for BoardError { pub struct BoardRegistration { /// Watch receiver for the board's current state. pub state_rx: watch::Receiver, + + /// Optional command sender for board-specific control and queries. + pub command_tx: Option>, +} + +/// Board-specific control and query commands exposed to higher layers. +pub enum BoardCommand { + /// Query one BZM2 ASIC's internal DTS/VS telemetry through a live hash thread. + QueryBzm2DtsVs { + thread_index: usize, + asic: u8, + reply: oneshot::Sender>, + }, + QueryBzm2Noop { + thread_index: usize, + asic: u8, + reply: oneshot::Sender>, + }, + QueryBzm2Loopback { + thread_index: usize, + asic: u8, + payload: Vec, + reply: oneshot::Sender, BoardError>>, + }, + ReadBzm2Register { + thread_index: usize, + asic: u8, + engine_address: u16, + offset: u8, + count: u8, + reply: oneshot::Sender, BoardError>>, + }, + WriteBzm2Register { + thread_index: usize, + asic: u8, + engine_address: u16, + offset: u8, + value: Vec, + reply: oneshot::Sender>, + }, + DiscoverBzm2Engines { + thread_index: usize, + asic: u8, + tdm_prediv_raw: u32, + tdm_counter: u8, + timeout_ms: Option, + reply: oneshot::Sender>, + }, } /// Helper type for async board factory functions diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index 73f7835d..64abf54f 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -15,6 +15,7 @@ use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, asic::hash_thread::HashThread, backplane::Backplane, + board::bzm2::Bzm2VirtualDeviceConfig, cpu_miner::CpuMinerConfig, job_source::{ SourceCommand, SourceEvent, @@ -24,7 +25,10 @@ use crate::{ }, scheduler::{self, SourceRegistration}, stratum_v1::{PoolConfig as StratumPoolConfig, TcpConnector}, - transport::{CpuDeviceInfo, TransportEvent, UsbTransport, cpu as cpu_transport}, + transport::{ + CpuDeviceInfo, TransportEvent, UsbTransport, VirtualDeviceInfo, cpu as cpu_transport, + virtual_device, + }, }; /// The main daemon. @@ -78,6 +82,22 @@ impl Daemon { } } + if let Some(config) = Bzm2VirtualDeviceConfig::from_env() { + info!( + serials = config.serial_paths.len(), + baud = config.baud_rate, + "BZM2 virtual board enabled" + ); + let event = TransportEvent::Virtual( + virtual_device::TransportEvent::VirtualDeviceConnected(VirtualDeviceInfo { + device_type: "bzm2".into(), + device_id: config.device_id(), + }), + ); + if let Err(e) = transport_tx.send(event).await { + error!("Failed to send BZM2 virtual board event: {}", e); + } + } // Board registration channel: backplane forwards board // registrations here, the API server collects and serves them. let (board_reg_tx, board_reg_rx) = mpsc::channel(10); diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index cd4426be..c8e563da 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -561,6 +561,10 @@ impl Scheduler { "Thread status" ); } + + HashThreadEvent::TelemetryUpdate(_) => { + trace!(thread = %thread_name, "Thread telemetry update"); + } } } diff --git a/mujina-miner/src/transport/mod.rs b/mujina-miner/src/transport/mod.rs index 45457d93..e38aefb8 100644 --- a/mujina-miner/src/transport/mod.rs +++ b/mujina-miner/src/transport/mod.rs @@ -8,6 +8,7 @@ pub mod cpu; pub mod serial; pub mod usb; +pub mod virtual_device; // Re-export transport implementations pub use cpu::CpuDeviceInfo; @@ -16,6 +17,7 @@ pub use serial::{ SerialWriter, }; pub use usb::{UsbDeviceInfo, UsbTransport}; +pub use virtual_device::VirtualDeviceInfo; /// Generic transport event that can represent different transport types. #[derive(Debug)] @@ -25,6 +27,9 @@ pub enum TransportEvent { /// CPU miner virtual device event Cpu(cpu::TransportEvent), + + /// Generic virtual device event + Virtual(virtual_device::TransportEvent), } /// Common trait for transport discovery (future enhancement). diff --git a/mujina-miner/src/transport/serial.rs b/mujina-miner/src/transport/serial.rs index 58dd4676..776592ed 100644 --- a/mujina-miner/src/transport/serial.rs +++ b/mujina-miner/src/transport/serial.rs @@ -120,16 +120,19 @@ struct SerialInner { } /// Reader half of a split serial stream. +#[derive(Clone)] pub struct SerialReader { inner: Arc, } /// Writer half of a split serial stream. +#[derive(Clone)] pub struct SerialWriter { inner: Arc, } /// Control handle for a split serial stream. +#[derive(Clone)] pub struct SerialControl { inner: Arc, } diff --git a/mujina-miner/src/transport/virtual_device.rs b/mujina-miner/src/transport/virtual_device.rs new file mode 100644 index 00000000..0f2109b7 --- /dev/null +++ b/mujina-miner/src/transport/virtual_device.rs @@ -0,0 +1,23 @@ +//! Generic virtual device transport. +//! +//! Virtual boards are injected by configuration rather than hardware discovery. + +/// Transport events for generic virtual devices. +#[derive(Debug)] +pub enum TransportEvent { + /// A virtual device was connected. + VirtualDeviceConnected(VirtualDeviceInfo), + + /// A virtual device was disconnected. + VirtualDeviceDisconnected { device_id: String }, +} + +/// Information about a generic virtual device. +#[derive(Debug, Clone)] +pub struct VirtualDeviceInfo { + /// Virtual board type identifier. + pub device_type: String, + + /// Unique identifier for this virtual instance. + pub device_id: String, +}