Mining Bitcoin in my hallway — and the home automation that decides when to do it
A 21energy Ofen 2 sits in my hallway — heating the air and hashing Bitcoin. This is the Home Assistant logic that decides when, why, and at what tier.
There is a small box in my house that does two things at once. It hashes Bitcoin and it heats the air around it. The air is for the family. The home-automation fun is for me. The Bitcoin is, ostensibly, for my wife.
It is a 21energy Ofen 2 — German for "oven", which is exactly what it looks like and sort of what it is. ~3 kW of compute that turns electricity into hash rate and a steady, dry warmth that is genuinely pleasant in a Swedish winter. The marketing pitch writes itself: if you were going to heat the room anyway, why not earn back some of the electricity cost in sats?
The pitch glosses over the part where electricity prices in Sweden swing from negative to "are-you-kidding-me" inside a single afternoon, and where Bitcoin's USD price cratered into the dirt right when I plugged the thing in. So this is not a story about turning on a miner. It is a story about teaching a miner when to be on, what tier to run at, and — most importantly — how to weigh "lose 0.20 SEK/kWh today" against "what if BTC is worth a multiple of this by the time I'd actually sell."
The whole thing now runs on Home Assistant. Here is what it took, why each part exists, and where the abstraction leaks.
The hardware, briefly
The Ofen 2 ships with a tiny embedded controller that exposes a local HTTP API. Nominally it is locked behind nginx Basic auth on /, but the actual control endpoints live under /21control/* and — for reasons I will not look too closely at — are wide open on the LAN. They give you everything: hashrate, share quality, chip temperature, power draw in watts, the currently selected power tier, and POST endpoints to flip those.
There are five tiers, numbered 0 through 4. I assumed it was 1–3 for the first week and ran the miner at half the hashrate I could have. The documentation says nothing about this. The way I figured it out was by curl-ing tier 5 and watching the controller return 400, then tier 4 and watching it return 201. Hardware probing as documentation.
The other detail worth knowing: the /heater/enable endpoint accepts JSON, but the body has to be exactly {"enabled": true}. Not {"enable": true}, not true, not {"value": true}. I tried all of them. Each one returned 400 with no useful error. This is a recurring theme.
Phase one: just see the thing
Before any logic, I needed Home Assistant to see the miner. That meant a rest: integration polling the local endpoint every 30 seconds and unpacking a fairly nested JSON blob into individual sensors:
rest:
- resource: http://<ofen-local-ip>/21control/heater/status/summary
scan_interval: 30
sensor:
- name: "Ofen Hashrate"
unique_id: ofen_hashrate
unit_of_measurement: "TH/s"
value_template: >-
{{ (value_json.miningDevices.lastSummaries[0]
.miner_stats.real_hashrate.last_5m.gigahash_per_second
| float(0) / 1000) | round(2) }}
- name: "Ofen Power Now"
unit_of_measurement: "W"
value_template: "{{ value_json.miningDevices.powerConsumptionW | int(0) }}"
- name: "Ofen Efficiency"
unit_of_measurement: "J/TH"
value_template: >-
{{ value_json.miningDevices.lastSummaries[0]
.power_stats.efficiency.joule_per_terahash | float(0) | round(2) }}The 30-second cadence is a deliberate compromise: short enough that the wall tablet on the kitchen counter feels live, long enough that we are not DDoSing a single-board computer that is, again, primarily a heater.
Three more REST integrations fill in the rest of the world:
- CoinGecko for BTC price in SEK and the 24h change. 5-minute polling — they rate-limit, and the price moves slower than my dashboard's refresh rate anyway.
- mempool.space for current network difficulty and total network hashrate. 30-minute polling. The numbers barely move between difficulty adjustments.
- Braiins Pool for the financial truth. This is the credited reward — what I will actually be paid in BTC at month-end. It includes the pool's variance smoothing, so it is the only number that matters when comparing to the spot price I paid for electricity.
The Braiins integration cost me an afternoon. Their docs imply you authenticate with ?secret_token=... in the URL. That returned a 403 with "missing session cookie" — a misleading error if there ever was one. The actual answer is an HTTP header: X-SlushPool-Auth-Token. (The pool was renamed from Slush to Braiins years ago. The header was not.)
Once the sensors are in, the dashboard hero card writes itself. The four numbers I actually look at, plus the one sentence the system uses to explain itself:

Phase two: turning observation into economics
Hashrate and BTC price are not, on their own, decisions. The number you actually want is SEK per kWh of mining revenue. Below the spot price, you are losing money. Above it, you are making money. Subtract the two and you have the margin that should drive the on/off decision.
The math fits in three template sensors:
- name: "BTC SAT per TH per day"
state: >-
{% set reward = states('sensor.btc_block_reward') | float(0) %}
{% set hr_ehs = states('sensor.btc_network_hashrate') | float(0) %}
{{ ((reward * 144) / (hr_ehs * 1e6)) | round(2) }}
- name: "Ofen Revenue SEK per kWh"
state: >-
{% set spt = states('sensor.btc_sat_per_th_per_day') | float(0) %}
{% set eff = states('sensor.ofen_efficiency') | float(0) %}
{% set price = states('sensor.btc_price_sek') | float(0) %}
{% set sat_per_kwh = (3600000 / eff) * (spt / 86400) %}
{{ ((sat_per_kwh * price) / 100000000) | round(3) }}
- name: "Ofen Margin SEK per kWh"
state: >-
{% set rev = states('sensor.ofen_revenue_sek_per_kwh') | float(0) %}
{% set spot = states('sensor.electricity_price_langrevsvagen_18') | float(0) %}
{{ (rev - spot) | round(3) }}Walking through it: 144 blocks per day × current block reward (3.125 BTC + average fees, in sats) divided by the network's total exahash gives you sat-per-TH-per-day. Multiply by your TH/s rate, divide by your power draw to get sat-per-kWh, multiply by BTC price, you have SEK per kWh of revenue. Subtract spot price, you have margin.
At today's numbers — ~948 EH/s of network hashrate, ~3.13 BTC reward — that comes out to about 47.6 sats per TH per day. My Ofen does ~63 TH/s. So roughly 3,000 sats a day. At a depressed Bitcoin price of ~180,000 SEK, that is about 5 SEK of revenue per day, on top of the heating value of the spent kWh. Not exactly retire-on-the-couch numbers.
The interesting realization here is that revenue per kWh is independent of how long you run. If you mine for one hour, you earn 1/24th of the daily figure. If you mine for ten hours, you earn 10/24. So the on/off question reduces, every two minutes, to a single comparison: is the margin right now better than the alternative right now?
Phase three: the priority chain
This is where the logic stops being math and starts being household politics.
The decision engine is a single Home Assistant automation that fires every two minutes (and on any state change to its inputs) and walks down a priority chain. Whichever condition matches first wins, sets the human-readable decision text, and issues the corresponding REST command if needed.
priority chain (lowest → highest)
off (default — not profitable, no surplus)
↑ heat-credit on AND house cold "we'd burn this kWh anyway"
↑ margin ≥ min_margin "actually profitable"
↑ solar surplus > Ofen min draw "free electrons from the roof"
↑ automation_enabled OFF "leave it alone, I'm tinkering"
↑ Mama mode on "wife wins"
↑ force_off on "absolute emergency stop"Each level overrides everything below it. The reasoning for the order is roughly:
- Force off is a hard kill. It exists because there are real situations — a thermal alarm, a fire drill, a child sleeping next to the box — where I want one toggle that means it.
- Mama mode is above auto, deliberately. My wife loves the warmth, and on the long arc of Bitcoin she is a little more bullish than I am. If she wants the miner on, the miner is on. The decision text I made the automation print when this branch wins is
"ON — Mama mode (warmth wins)". This is the most-printed string in the system. - Automation off is for me when I'm tinkering. It tells the engine to leave the current state alone — useful when I'm probing the API by hand and don't want the engine fighting me.
- Solar surplus beats the margin check because surplus is free. If the roof is exporting more than ~800 W, mining is strictly better than feeding the grid at the export rate (which is about a fifth of the import rate).
- Margin is the boring economic case. We'll come back to this — it has a twist.
- Heat credit is the most interesting branch. The argument: if the house is below its setpoint, something is going to spend electricity to heat it. If that something is the heat pump, the kWh is gone forever. If it's the Ofen, the kWh becomes both heat and sats. So when the house is cold, we mine even at a small loss because the loss is offset by displaced heat-pump cost.
Phase four: the bug that disabled the miner the moment it deployed
The first time I shipped the decision engine, it disabled the miner within fifteen seconds. The problem was subtle. The trigger fires at boot. At boot, the margin sensor hadn't yet computed (it depends on three other sensors that hadn't yet polled). So it returned 0. Zero is below the (then default) threshold of 0.05. The default branch fired. It called rest_command.ofen_disable. The miner went off.
The fix had two parts. First, default Mama mode to on so the high-priority branch wins on cold-start. Second, the engine itself never enables the miner when entering the off branch unless it had previously been on — which sounds obvious, but the implementation was wrong.
Worse, every disable/enable cycle reset the tier back to the default (which is 4 — max). I noticed when my wife pointed out that the miner was suddenly louder than it had been the day before. The fix was to also re-issue rest_command.ofen_set_tier whenever the current tier diverges from the user-selected target. The engine now reconciles three things every two minutes: enable state, tier, and decision text.
There is a deep lesson here about state machines that operate on hardware: never assume the device remembers anything between commands. Always re-assert the desired state. The Ofen forgets its tier on enable. The dashboard cannot.
Phase five: the HODL multiplier
The decision chain above is correct in a world where Bitcoin's price is what it will always be. That is not the world.
My wife pointed this out, more or less directly, while I was explaining why the miner had been off all morning. "Bitcoin is cheap right now," she said. "Isn't that exactly when you want to be mining?"
She was right, and the engine was wrong.
The economic calculation as written above asks: if I sell the sats I mine right now, at the current BTC price, do I clear my electricity cost? That's a defensible question for a strict day-trader. It is the wrong question if you intend to hold what you mine. If you believe BTC will be worth N× its current price at some future sell date, then the revenue you're calculating today is N× too low.
So we added a multiplier. A single helper, input_number.bitcoin_hodl_multiplier, range 1.0 to 10.0, default 2.0. Two new template sensors mirror the existing revenue and margin, scaled by it:
- name: "Ofen Adjusted Revenue SEK per kWh"
state: >-
{% set rev = states('sensor.ofen_revenue_sek_per_kwh') | float(0) %}
{% set mult = states('input_number.bitcoin_hodl_multiplier') | float(1) %}
{{ (rev * mult) | round(3) }}
- name: "Ofen Adjusted Margin SEK per kWh"
state: >-
{% set rev = states('sensor.ofen_adjusted_revenue_sek_per_kwh') | float(0) %}
{% set spot = states('sensor.electricity_price_langrevsvagen_18') | float(0) %}
{{ (rev - spot) | round(3) }}The decision engine now reads ofen_adjusted_margin_sek_per_kwh instead of the raw one. And we dropped the threshold from 0.05 SEK/kWh to -0.20 — explicit acknowledgement that we are willing to lose 20 öre per kWh today in exchange for the option value of the sats appreciating later.
This is a strange thing to write down. It is also the most honest version of what we are actually doing. Most "is this profitable" calculations in mining hide their assumed exit price inside the implicit "I will sell at today's price". Making the assumption a slider you can drag, with a default that says "I think BTC at least doubles," forces the assumption out into the open. You can see it. You can argue with it. You can change it on the way past the kitchen.
The decision text the automation now writes is also more honest. Instead of "ON — profitable +0.16 SEK/kWh" it now writes "ON — profitable +0.16 SEK/kWh (raw -0.61 × 2.0)". The raw number is right there. You can see what the market actually says, and what we believe on top of it.
Phase six: showing the bet
The HODL multiplier is hidden value. A 2× assumption is a small thing on a slider, but if the BTC price genuinely 5×s, the difference between "I mined through the depression" and "I sat the depression out" becomes life-changing for a household-scale stack.
So I built a card on the Bitcoin dashboard that makes the asymmetric bet visible. It is called "If Bitcoin moons". It takes the BTC mined today plus the unpaid pool balance — call this "the stack" — and shows what it would be worth at 1×, 2×, 5×, and 10× the current BTC price. Beneath each scenario, it shows the same calculation against all-time mined. The 1× column is muted; the 10× column glows fuchsia. The whole thing is purely informational — it influences no automation logic — but it is the answer to the only question my wife actually wants to ask the miner: if you're right, how much?

The card matters more than I expected. Mining at a -20 öre/kWh loss feels bad in the abstract. Looking at "today's stack at 5× = 18 SEK" feels neutral. Looking at all-time mined at 5× = N thousand SEK, where N is steadily climbing, makes the bet feel real. Without the visualisation it is just a number on a slider. With it, it is a thesis you can see compounding.
Phase seven: the dashboard
None of this would matter if it lived in the standard Home Assistant Lovelace UI. I have built a custom React dashboard for the house — Casa Belitz, served from an iframe on a wall tablet in the kitchen — and Bitcoin has its own page in it. The page has six cards:
- Hero — current hashrate, current power, current raw margin, current efficiency, plus the live decision text rendered as an italic pull-quote.
- Today — earnings (Braiins-credited), cost (kWh × today's average spot), profit (the difference), and energy used.
- Pool — Braiins balance, pending estimated reward, all-time reward, pool hashrate at 5m and 24h, worker status.
- Future value — the HODL scenario card described above.
- Market — BTC price in SEK with 24h change, network hashrate, network difficulty, current SAT/TH/day.
- Mining stats — share accept rate, accepted/rejected counts, best share, found blocks (zero, but the column makes me hope), chip temperature with thermal coloring, efficiency, current tier.
- Controls — four toggles (Mama mode, Auto, Heat credit, Force off), a tier picker (0–4), a min-margin slider, and the HODL multiplier slider.

The aesthetic is deliberately not Grafana. Grafana shows you everything and tells you nothing. This dashboard shows you what is happening and what the system has decided about it, with one number bolded per card and the rest pushed down to small grey type. The decision pull-quote on the Hero card is the soul of the page — it is the system explaining itself in a sentence.
What I have learned
A few things, some about Bitcoin mining, most not.
Always re-assert state to dumb hardware. The Ofen forgets its tier on every enable cycle. Anything you commanded the device to do five minutes ago, command it again now. Cheap firmware is amnesiac firmware.
Make assumptions visible. The HODL multiplier is the most important thing in the system not because of its math but because of its existence. The moment you put your "I think this asset goes up" assumption on a slider, you are forced to argue with it. Hidden in a spreadsheet, it never gets challenged.
Priority chains beat scoring functions for household automation. I started designing this with a weighted score — surplus contributes X, margin contributes Y, heat-need contributes Z, total above threshold means "on." It was clever and unreadable. The chain — force off, then Mama, then auto, then surplus, then margin, then heat — is dumber and it works. When the miner does something surprising, you can read the chain top-to-bottom and the answer is one of seven things.
The wife test is real. This system has exactly one user who is not me, and she does not run journalctl. The single most important UI element is the sentence the engine writes at the top of the page explaining what it is currently doing and why. Everything else is decoration.
Two-minute polling is the right cadence. Faster, and you are oscillating around margin thresholds and burning life off the heater's relay. Slower, and the dashboard feels dead. Two minutes matches the rate at which spot prices update and is well below the rate at which household conditions (temperature, occupancy, sun) change.
Mining at a loss can be the right call. Strict per-kWh profit is a flawed objective. The right objective is something like: over the next 12 months, will this hardware be net-positive against the alternative of the heat pump running on the same kWh? That calculation depends on a BTC price you do not know, an electricity price curve you do not know, and a heating load you do not know. So it bottoms out in a slider. The slider is honest about that.
The last thing — and this one I did not expect when I wired in the first REST sensor — is that the project is not really about Bitcoin. It is about giving a household the controls to make a contested decision (should we be doing this?) and the visibility to see whether the decision was a good one. The Bitcoin part is just the test case where the disagreement is sharpest.
The Ofen hums. The kitchen is warm. The dashboard says "ON — Mama mode (warmth wins)". The stack ticks up by a few thousand sats every day. We will see, in five years or so, whether the slider was right.