~/forbannet/blog~clbiteyecl-minerpulse
now compiling: io_uring branch, --releaselatest push: e8af13c → main · 12 min agoreading: "What Every Programmer Should Know About Memory" — Drepper, 2007currently playing with: ftrace + perf for syscall latencyopen PRs: 3 · issues triaged today: 14now compiling: io_uring branch, --releaselatest push: e8af13c → main · 12 min agoreading: "What Every Programmer Should Know About Memory" — Drepper, 2007currently playing with: ftrace + perf for syscall latencyopen PRs: 3 · issues triaged today: 14
Techie#5782
Techie#5782guest
//.post.01.programming pinned.published
DETOURS.DLL / 0x4C3F / ws2_32::connect()  ws2_32::send()  ws2_32::recv()
55                 PUSH    EBP
8B EC              MOV     EBP, ESP
83 EC 14           SUB     ESP, 14h
E8 25 00 00 00     CALL    DetourTransactionBegin
0F B6 45 08        MOVZX   EAX, BYTE [EBP+8]
KERNELRIOT / reader::post / markdown::render / prism::pending
55                 PUSH    EBP
8B EC              MOV     EBP, ESP
post.0x01programming.reader
programmingasicminingsre

μBiT::EYE MinerPulse

MicroBT WhatsMiner Observability Tool. Free and Open Source. It is really, really cool, come check it out.

/blog/clbiteyecl-minerpulse

μBiT::EYE - MinerPulse: The ASIC Monitor That Rocks

Yo miners, gather 'round. Let me tell you about that time my WhatsMiner M30S++ tried to cosplay as a toaster in a Brazilian summer. 44°C ambient. Error 352. The chips were begging for mercy in Portuguese.

🔥 Spoiler Alert 🔥: It almost burned. So I built something about it.


Chapter 1: MicroBT UI Looks Like AOL Browser

Let's face it, MicroBT's stock interface is what happens when you let 90s web design and Soviet-era UX have a baby. You want to check your hashrate? Load a page. Want temps? Load another page. Want to see if your PSU is dying? Good luck finding that buried in a table that hasn't been redesigned since the Ming Dynasty.

  Stock UI μBiT::EYE
Load Time Dial-Up Would Be Faster 500ms boot. Microsecond responses.
Real-Time Updates Relies on Prayers WebSocket. Live. Always.
Troubleshooting Error: ¯\_(ツ)_/¯ 404 Error 352: "Chip overheat" + how to fix it
Theme Geocities Midnight Edition Tron Woke Up and Chose Violence
Multi-Miner One tab per miner. Manually. Entire fleet. One dashboard.
Prometheus What's a Prometheus? Native /metrics endpoint. Grafana ready.
Error Codes Raw number. Google it yourself. 150+ codes with meaning AND solution.

I decided to take matters into my own hands. And trust me, I fixed that.


Chapter 2: Architecture

No Docker. No Kubernetes. No microservices. No twelve-factor cargo cult. One binary. One process. Boots in 500ms. Uses about ~33MB RAM. Runs on a Raspberry Pi if you want.

┌──────────────────────────────────────────────────────────┐
│                        μBiT::EYE                         │
│                                                          │
│   Kestrel ───→ SignalR Hub ───→ CommandDispatch          │
│   HTTP/WS      (LiteHub)        Will → Handler           │
│      │              │                │                   │
│      │        WiredStream       MinerService             │
│      │        (OPENFIRE)        (TCP to ASICs)           │
│      │                                                   │
│   /metrics ─── PrometheusCollector                       │
│                Per-miner, per-slot, per-PSU telemetry    │
└──────────────────────────────────────────────────────────┘

The stack:

  • Backend: C# / .NET 10, single binary, single process
  • Transport: SignalR over WebSocket with MessagePack (binary, fast)
  • Protocol: Command pattern. Will string dispatches to handler. WiredAnswer comes back with microsecond benchmarks on every single response.
  • Frontend: Vanilla JS. Zero frameworks. Handcrafted kernel UI.
  • Metrics: Native Prometheus exporter at /metrics

The command flow is five steps:

  1. Client sends a command over WebSocket (MessagePack binary)
  2. SignalR Hub receives it, resolves the user, creates execution context
  3. CommandDispatch looks up the Will string in a dictionary, finds handler
  4. Handler does its thing (query miners, read config, whatever)
  5. OPENFIRE sends the response back with microsecond benchmarks

Every response includes a BENCHMARK object. You can see exactly how many microseconds your command took. Because if you can't measure it, you can't overclock it.


Chapter 3: The Command Pattern

Every operation in μBiT::EYE is a command. You slap a [WiredCommand] attribute on a static method, the system finds it at boot, registers it in a dictionary, and it's live. No routing tables. No controllers. No dependency injection ceremony.

[WiredCommand<MinerGetRequest, MinerGetResponse>(
    "MINER_GET",
    MinAccessLevel = 0,
    RequiresAuth = false,
    Description = "Get full telemetry for a single miner")]
public static void MINER_GET(WiredStream ws)
{
    ws.Mark("Start");

    var payload = ws.GetPayload<MinerGetRequest>();
    var miner = MinerService.GetMiner(payload.MinerID);

    ws.Mark("Querying");
    var snapshot = MinerService.QueryMiner(miner);
    ws.Mark("QueryComplete");

    ws.OPENFIRE(new MinerGetResponse
    {
        Success = true,
        Miner = snapshot
    }).Wait();
}

That's it. That's a complete API endpoint. The attribute defines the command name, access level, and request/response types. WiredStream gives you the execution context, payload deserialization, performance marks, and OPENFIRE sends the response back through whatever transport brought the request in.

The generic types <MinerGetRequest, MinerGetResponse> aren't just decoration. The system uses them for self-documentation. Send LIST_ENDPOINTS_OPTIMIZED and you get the full schema of every command, with request and response types, auto-generated from reflection. The API is the documentation.

// From the browser console
BrutalNetwork.send('LIST_ENDPOINTS_OPTIMIZED').then(r => console.log(r.Obj))

The cave wall draws itself.


Chapter 4: WiredStream, the Execution Brain

Every command gets a WiredStream. It holds the request, the user context, the response writer, and a zero-allocation performance tracker using C# InlineArray.

// InlineArray: 8 performance marks, zero heap allocation
[InlineArray(8)]
public struct MarkBuffer
{
    private PerformanceMark _element0;
}

The five-tier payload deserialization cascade handles whatever the transport throws at it:

TIER 1: Direct cast         ~10ns    (object already is T)
TIER 2: MessagePack bytes   ~1-2μs   (raw bytes from wire)
TIER 3: MessagePack re-ser  ~3-5μs   (dynamic object roundtrip)
TIER 4: JsonElement         ~5-10μs  (SignalR JSON fallback)
TIER 5: JSON roundtrip      ~10μs+   (last resort, we log it)

Try the fast path first. Fall through if needed. Always deserialize. Never crash.


Chapter 5: MinerService, the TCP Muscle

The backend doesn't run background loops. It doesn't poll on its own. It has no autonomy. The frontend says "go", the backend goes, returns data, shuts up. The setInterval in JavaScript IS the poller.

// Frontend drives everything
setInterval(() => {
    BrutalNetwork.send('MINER_POLL').then(r => updateDashboard(r.Obj))
}, 10000)

When polled, MinerService fires all four WhatsMiner API commands in parallel per miner:

// All 4 TCP commands fire simultaneously
var summaryTask = SendCommand(miner.IP, miner.Port, "summary");
var edevsTask   = SendCommand(miner.IP, miner.Port, "edevs");
var psuTask     = SendCommand(miner.IP, miner.Port, "get_psu");
var errorTask   = SendCommand(miner.IP, miner.Port, "get_error_code");

await Task.WhenAll(summaryTask, edevsTask, psuTask, errorTask);

The old code did these sequentially. Four round trips in series. Now it's one round-trip time for all four. The TCP reader uses a proper read loop with null-byte termination detection, so no more truncated JSON from a single ReadAsync.


Chapter 6: The Firmware Problem

WhatsMiner has two different API response formats. Because their firmware engineers apparently never talked to each other.

Format A (M30S++, M50):

{
  "STATUS": [{"STATUS": "S", "Msg": "Summary"}],
  "SUMMARY": [{"MHS av": 112000000, "Temperature": 65.0}],
  "id": 1
}

Format B (M30S_V10):

{
  "STATUS": "S",
  "When": 1775416328,
  "Code": 131,
  "Msg": {
    "STATUS": [{"STATUS": "S", "Msg": "Summary"}],
    "SUMMARY": [{"MHS av": 88000000, "Temperature": 72.0}]
  }
}

Same data, different wrapping. One function handles both:

private static JsonElement Unwrap(string json)
{
    var doc = JsonDocument.Parse(json);
    var root = doc.RootElement;

    // Format B: STATUS is string + Msg is object = wrapped
    if (root.TryGetProperty("Msg", out var msg) &&
        msg.ValueKind == JsonValueKind.Object &&
        root.TryGetProperty("STATUS", out var status) &&
        status.ValueKind == JsonValueKind.String)
    {
        return msg;  // Return inner content
    }

    return root;  // Format A: already clean
}

Every parser calls Unwrap() first. By the time the frontend gets a MinerSnapshot, all miners look identical. The firmware chaos dies in the backend. The frontend never sees the difference.


Chapter 7: The Error Bible

150+ WhatsMiner error codes. Not just "what's wrong" but "how to fix it". Every error comes back with meaning, solution, category, and severity.

352 => (
    "Hashboard 2 over-temperature protection triggered",
    "Check ambient temperature",
    "Temp Sensor",
    "temp"
),
600 => (
    "Ambient temperature too high",
    "Reduce environment temp below 35°C for normal mode",
    "Environment",
    "temp"
),

The frontend gets actionable data:

{
  "Code": 352,
  "Meaning": "Hashboard 2 over-temperature protection triggered",
  "Solution": "Check ambient temperature",
  "Category": "Temp Sensor",
  "Severity": "temp",
  "Timestamp": "2026-04-06 02:41:36"
}

No googling error codes. The system tells you what's wrong and what to do about it. Covers fans, PSU (including hex codes from PSU internal diagnostics), temperature sensors, EEPROM, hashboards, chip-level errors, firmware, pools, security, and even virus detection.

That error 352 on my miner? "Check ambient temperature." Yeah. 44°C in Brazil. The solution is to move to Finland. Not very helpful, but at least accurate.


Chapter 8: Prometheus - Your ASICs in Grafana

Hit /metrics on the server and you get everything in standard Prometheus exposition format. No sidecar. No exporter binary. No agent. Just one GET request.

Server metrics:

liteioctl_uptime_seconds 3421.5
liteioctl_memory_bytes 104857600
liteioctl_connections 3
liteioctl_commands_total 1847
liteioctl_command_avg_us{will="PING"} 8.6
liteioctl_command_avg_us{will="MINER_POLL"} 4521.3

Per-miner telemetry:

miner_online{id="56",ip="192.168.15.56"} 1
miner_hashrate_ths{id="56",ip="192.168.15.56"} 112.10
miner_temp_chip_max{id="56",ip="192.168.15.56"} 78.16
miner_temp_env{id="56",ip="192.168.15.56"} 32.0
miner_power_watts{id="56",ip="192.168.15.56"} 3472
miner_efficiency_jpth{id="56",ip="192.168.15.56"} 31.0
miner_fan_in{id="56",ip="192.168.15.56"} 5800
miner_accepted{id="56",ip="192.168.15.56"} 6301
miner_uptime_seconds{id="56",ip="192.168.15.56"} 1700936

Per-slot hashboard breakdown:

miner_slot_hashrate_ths{id="56",slot="0"} 37.80
miner_slot_hashrate_ths{id="56",slot="1"} 37.20
miner_slot_hashrate_ths{id="56",slot="2"} 37.00
miner_slot_chip_temp_max{id="56",slot="0"} 78.0
miner_slot_chips{id="56",slot="0"} 78
miner_slot_freq{id="56",slot="0"} 680

PSU diagnostics:

miner_psu_temp{id="56",model="P222B"} 52.0
miner_psu_voltage_in{id="56"} 212.5
miner_psu_fan{id="56"} 9824

Error codes with full labels:

miner_error_active{id="74",code="352",meaning="Hashboard 2 over-temp",category="Temp Sensor",severity="temp"} 1

Point Prometheus at it. Build a Grafana dashboard. Watch your money print. Or burn, if you're in Brazil.


Chapter 9: The API

Every command goes over WebSocket using MessagePack binary protocol. The API is self-documenting. Send one command, get every schema.

Command What It Does
MINER_SCANSweep IP range, discover WhatsMiner devices on port 4028
MINER_POLLQuery all registered miners, return fresh telemetry snapshots
MINER_GETFull detail for single miner: devices, PSU, errors, chip data
MINER_ADDManually register a miner by IP
MINER_REMOVERemove miner from registry
MINER_LISTList registered miners (instant, no TCP queries)
MINER_CLEARWipe the miner registry
SERVER_STATUSUptime, memory, connections, ports, command stats
SERVER_RESTARTRestart Kestrel. Reloads config. Client auto-reconnects.
CONFIG_GETDump running configuration as key/value pairs
CONFIG_SETChange config, writes to app.xml, tells you if restart is needed
CONFIG_RELOADRe-read app.xml from disk without restarting
PINGConnectivity test. About 8μs round trip. Not a typo.
LIST_ENDPOINTS_OPTIMIZEDFull API schema with request/response types. Self-documenting.
LIST_MODULESGroup commands by module

From the browser console, everything is one line:

// Scan a network
BrutalNetwork.send('MINER_SCAN', {
    IpStart: '192.168.15.1',
    IpEnd: '192.168.15.254'
}).then(r => console.log(r.Obj))

// Poll every 10 seconds
setInterval(() => {
    BrutalNetwork.send('MINER_POLL').then(r => updateDashboard(r.Obj))
}, 10000)

// Get one miner's full detail
BrutalNetwork.send('MINER_GET', { MinerID: 74 }).then(r => console.log(r.Obj))

// Check server status
BrutalNetwork.send('SERVER_STATUS').then(r => console.log(r.Obj))

// Change a config value and save to disk
BrutalNetwork.send('CONFIG_SET', {
    Values: { 'HTTPPorts/Port': '8080' }
}).then(r => console.log(r.Obj.Message))
// "Config saved. Restart required for changes to take effect."

Chapter 10: Configuration

All settings live in app.xml. Change them from the UI with CONFIG_SET (writes to disk), edit the file directly and call CONFIG_RELOAD, or just restart the process. One file. No environment variables. No .env. No secrets manager. XML on disk. Cave man write settings on wall. App read wall at boot. Change wall, restart app. New reality.

<?xml version="1.0" encoding="utf-8"?>
<LiteIOCTL>
  <WebRoot>html</WebRoot>
  <UploadMaxSizeMB>100</UploadMaxSizeMB>
  <Domain>localhost</Domain>

  <HTTPPorts>
    <Port>1442</Port>
  </HTTPPorts>

  <HTTPSPorts>
    <Port>1443</Port>
  </HTTPSPorts>

  <HTTPSCertificate>
    <Enabled>false</Enabled>
    <FilePath>wildcard.pfx</FilePath>
    <Password></Password>
  </HTTPSCertificate>

  <SignalR>
    <MaxMessageSizeMB>10</MaxMessageSizeMB>
    <KeepAliveSeconds>15</KeepAliveSeconds>
    <ClientTimeoutSeconds>30</ClientTimeoutSeconds>
  </SignalR>

  <CorsOrigins>
    <!-- Empty = allow all -->
  </CorsOrigins>

  <SpaRewrites>
    <Path>/app</Path>
  </SpaRewrites>
</LiteIOCTL>

Changes to ports, HTTPS, or SignalR settings need a SERVER_RESTART. Logging and domain changes apply immediately. CONFIG_SET tells you which is which.


Chapter 11: Quick Start

# Build
dotnet build

# Run
dotnet run

# That's it. Open http://localhost:1442
# Prometheus at http://localhost:1442/metrics

Requirements: .NET 10 SDK. That's it. No Docker. No Node. No npm. No webpack. No node_modules black hole that weighs more than the actual application.


Chapter 12: Security

  • Read-only. Only queries miners via standard WhatsMiner API on port 4028. Same commands the stock UI uses. Never writes to firmware. Never touches your mining config.
  • No auth data stored. Miner registry is in-memory. Lost on restart. No database. No state files.
  • One config file. app.xml is the only thing on disk. That's the entire ops surface.
  • Code is on GitHub. github.com/layer07/ubit-eye. Read it. Audit it. Fork it.

Worst case? Stop the process. Delete the folder. Poof. Nothing was ever installed, no services registered, no registry keys touched, no system files modified.


The Real Talk

Alright, enough with the chapter titles. I built this tool because MicroBT's monitoring is genuinely awful and commercial alternatives are either expensive, bloated, or both.

This is a lean, open-source ASIC monitor that tracks everything that matters: VIN fluctuations, ambient and PSU temperatures, hashboard and chip temps per-slot, fan speeds, power efficiency, pool rejection rates, error codes with human-readable meanings and solutions. It exports everything to Prometheus for proper time-series monitoring with Grafana.

The backend is a command-pattern WebSocket server in C# that boots in under a second and responds in microseconds. The frontend is handcrafted vanilla JS with no framework dependencies. The whole thing is a single binary that runs on anything with .NET 10.

It handles both WhatsMiner firmware response formats transparently, has a self-documenting API, a built-in error code encyclopedia with 150+ entries, and runtime configuration that persists to disk.

It's free, open source, and the code is clean enough that you can read it, understand it, and extend it.


Testing Environment:

  • M30S++ at stock 112 TH/s, running clean at 32°C ambient
  • M30S_V10 at 88 TH/s, slightly warm but holding
  • M30S++ throttled to 59 TH/s in Low mode because Brazil, 44°C ambient, error 352 screaming permanently
  • M50 at 114 TH/s, the efficient one
  • M50S at 126 TH/s, the new hotness
  • M21S at 56 TH/s, the old dog that the seller swore was "still good"

Result? Caught 3 blown fans and a dying hashboard before the ASICs themselves knew something was wrong.


Donations Welcome - I'll spend it all on industrial fans for grandma's mining closet:

BTC: bc1qjj5vqw9t6pl4lhydsspll075skfuxgqkj7u97m | Ko-Fi: ko-fi.com/soloween


🚀 GitHub: github.com/layer07/ubit-eye | Live Demo: ubit.kernelriot.com

/comments

comments (5)

Markdown supported, fenced code encouraged.

no comments yet — be the first.
// add to the thread
TE
posting as Techie#5782 guest
be excellent. ⇧⏎ for newline.