DCENT_OS Notifications Reference: MQTT, Home Assistant and Webhooks
Home » DCENT_OS — Open-Source Antminer Firmware » DCENT_OS Notifications Reference: MQTT, Home Assistant and Webhooks
Two ways DCENT_OS talks to something other than you: an MQTT publisher with Home Assistant auto-discovery, and an HTTP webhook dispatcher that speaks Discord, Slack and Telegram natively. Both ship switched off. This page is the reference for what each one emits, what it will accept back, and where the limits are.
How to read this page
Nothing on this page has been verified against a miner on a bench. Every statement here is a statement about the published GPL-3.0 source, or about the configuration file that ships with it. Each claim carries one of four grades:
- Verified (in source) — read in the public release tree; the file and line are printed beside it.
- Verified (shipped config) — a real key and value in the
dcentrald.tomlthat ships in the repository. - Inferred — arithmetic, or a consequence that follows from Verified facts. Labelled, never dressed as an observation.
- Unknown — the source does not say, or two files say different things. We box it rather than pick one.
There is deliberately no Supported grade on this page. That grade would mean tested on hardware, and we have not done that for anything described here. When we have, this page changes.
Both features ship disabled
[mqtt] enabled = false (dcentrald.toml:161) and
[webhook] enabled = false (dcentrald.toml:173).
Verified (shipped config). Nothing described here happens on a stock
configuration until you turn it on.
On this page
- MQTT: DCENT_OS is a client, not a broker
- What auto-discovery creates in Home Assistant
- The part worth copying: it refuses to show a modelled number as a measured one
- Letting Home Assistant write back — three setpoints, hard-clamped
- Availability: a dead miner shows as dead
- Webhook contracts: declared events and actual wired producers
- Your wallet address does not leave the miner in a webhook
- A dead endpoint cannot slow your miner down
- The Bitaxe side is a second implementation, not the same one
- What this page does not tell you
MQTT: DCENT_OS is a client, not a broker
This is worth stating plainly because the opposite is easy to assume. DCENT_OS does not run a
broker. It connects to one you already have — the broker key is an address it
dials. The module is titled "MQTT publisher with Home Assistant auto-discovery"
(dcentrald-api/src/mqtt.rs:1). Verified (in source).
Retained repository configuration example; check the installed configuration
(Verified (shipped config) — dcentrald.toml:160-167):
[mqtt]
enabled = false # master switch for the MQTT publisher + subscriber
broker = "mqtt://203.0.113.100:1883" # broker URL (mqtt:// or mqtts://)
topic_prefix = "dcentrald" # base topic; entities live under <prefix>/<name>
discovery = true # publish HA MQTT-discovery configs on connect
# username = "homeassistant" # optional broker credentials
# password = "secret"
publish_interval_s = 10 # stats publish cadence (seconds)
Note the key is topic_prefix and its shipped value is dcentrald. If you
have seen topicPrefix or a dcentos prefix written down somewhere, that is a
different spelling from the dashboard layer and it will not match the daemon's topic tree.
Two numbers exist for the publish cadence and both are real: the code default is
5 s (default_mqtt_interval() in config.rs), and the
dcentrald.toml that ships in the repository overrides it to 10 s
(dcentrald.toml:167). Your installed active configuration determines the cadence; the repository sample alone does not establish an image default. We print both
so that seeing "5" somewhere else does not read as a contradiction.
What auto-discovery creates in Home Assistant
On connect, with discovery = true, the daemon publishes retained discovery configs to
homeassistant/<domain>/dcentrald_<mac_id>/<entity_id>/config. All entities
share one device block: manufacturer "D-Central Technologies",
model from the unit's platform stamp (falling back to "Antminer"), and a
configuration_url resolved from the live management IP, then the hostname, then
http://dcentos.local (mqtt.rs:760-767, :98-107).
| HA domain | Object | Entity name | Unit | Defined at |
|---|---|---|---|---|
| sensor | hashrate | Hashrate | TH/s | :779-786 |
| sensor | temperature | Temperature | °C | :787-798 |
| sensor | power | Power | W | :799-806 |
| sensor | btu | BTU/h | BTU/h | :807-814 |
| sensor | fan_rpm | Fan RPM | RPM | :815-822 |
| sensor | efficiency | Efficiency | J/TH | :823-830 |
| sensor | accepted | Accepted Shares | — | :831-838 |
| sensor | rejected | Rejected Shares | — | :839-846 |
| sensor | uptime | Uptime | s | :847-854 |
| sensor | energy | Energy | kWh | :907-923 |
| sensor | power_provenance | Power Provenance | — (diagnostic) | :930-942 |
| binary_sensor | mining | Mining Active | — | :946-952 |
| binary_sensor | pool | Pool Connected | — | :953-962 |
The energy sensor carries device_class: energy and
state_class: total_increasing in kWh — the exact shape Home Assistant's Energy
dashboard requires, which is what makes a mining heater's kWh/day and cost/day graphable without a
template sensor (mqtt.rs:894-906).
The part worth copying: it refuses to show a modelled number as a measured one
Three of those sensors — power, BTU/h and efficiency — are derived from wall wattage, and wall wattage can be modelled rather than measured. The firmware handles this the hard way:
- They fail closed. Each value template resolves to
noneunless the daemon has publishedlive_power_available: e.g.{{ (value_json.wall_watts | round(0)) if value_json.live_power_available else none }}(mqtt.rs:802, and the same guard at:810,:826). A blank reading, not a plausible invented one. Verified (in source). - Provenance rides along as attributes. Power, BTU and efficiency each get
json_attributes_templatecarryingpower_source,power_source_detail,power_modeled,power_calibratedandpower_note(mqtt.rs:742,:883-886) — in the source's own words, "so a modeled reading is never displayed as if it were a wall-meter measurement" (mqtt.rs:879-882). - There is a sensor whose only job is to explain another sensor. "Power
Provenance" is a diagnostic-category text entity whose value is the human-readable reason a power
reading may be unknown (
mqtt.rs:925-942). - The heater's current temperature is the measured chain temperature, not an
invented room temperature — stated as such at
mqtt.rs:1031-1033.
The four provenance notes the daemon can emit are fixed strings
(dcentrald-api/src/lib.rs:706-737): "Miner load is sourced from live measured power
telemetry." · "Miner load is modeled from live runtime state with an operator wall-meter
calibration." · "Miner load is modeled from the live dispatcher estimate." ·
"Live miner power has not published a positive wall-power reading."
Letting Home Assistant write back — three setpoints, hard-clamped
The command surface is exactly three values, and they only appear when the daemon supplies a validated-setter sink. There is no profile switch, no night-mode toggle and no reboot over MQTT on the Antminer side.
| HA domain | Entity | Command topic | Range enforced |
|---|---|---|---|
| number | Fan PWM | <prefix>/fan_pwm/set | 0 – 30 % |
| number | Target Power | <prefix>/target_watts/set | 100 – 6000 W |
| climate | Space Heater | <prefix>/target_temp/set | 40 – 60 °C |
Each has a matching <prefix>/…/state topic where the daemon echoes the value it
actually applied after clamping — not the value you asked for (mqtt.rs:989).
The clamp behaviour, all Verified (in source):
- Commands are routed through the same validated setters the local REST API uses;
the subscriber "NEVER opens a new unclamped path" (
mqtt.rs:144-148). - The fan ceiling of 30 % is the home safety cap and is "pinned equal" to
dcentrald_hal::fan::PWM_SAFETY_MAXby a test (mqtt.rs:125-127). Publishing a higher number does not raise it — it is clamped (mqtt.rs:127-128). - The 60 °C temperature ceiling is chosen so a remote setpoint "can never park the PID target
at or above the danger line" (
mqtt.rs:138-141). - Non-numeric and non-finite payloads are rejected before dispatch (
mqtt.rs:232-247). - Retained messages on a
/settopic are ignored (mqtt.rs:561-568) — otherwise a retained setpoint would silently re-apply itself on every reconnect.
Availability: a dead miner shows as dead
The daemon registers offline as the broker-side Last Will on
<prefix>/availability, the same topic every discovery entity gates on, and
publishes a retained online when it connects (mqtt.rs:28-38,
:392-410). A crash or a power cut therefore flips every Home Assistant entity to
unavailable rather than freezing them on the last good reading. Verified (in source).
The connect sequence is deliberate and documented at mqtt.rs:328-336: subscribe
first, then retained discovery configs at QoS 1, then retained online, then the latest
state at QoS 0 and not retained.
Broker URLs are passed through the pool-URL sanitiser before they reach any log line or error
string (mqtt.rs:441, :668), so credentials embedded in a broker URL are
masked.
Three practical MQTT and webhook examples
These examples follow the beta20261002 source commit c0c7866. They explain configuration and message contracts; they are not a miner-bench or Home Assistant qualification. MQTT and webhooks default to disabled. Keep your exact control-board installation and recovery limits.
1. Give each miner its own MQTT topic prefix
Use your existing broker and connect Home Assistant's MQTT integration to the same broker. Replace the example hostname and account details. A distinct prefix per miner keeps their state and command topics separate.
[mqtt]
enabled = true
broker = "mqtt://broker.example:1883"
topic_prefix = "dcentos/heater-01"
discovery = true
username = "YOUR_BROKER_USERNAME"
password = "YOUR_BROKER_PASSWORD"
publish_interval_s = 10
This example deliberately sets a 10-second publish interval. The code default is 5 seconds; the repository sample uses 10. Your installed configuration determines the actual value. Supply both credential fields when your broker requires authentication. The mqtt:// example is plaintext and belongs on an operator-controlled trusted network. mqtts:// requires a build compiled with mqtt-tls and suitable platform trust roots; a build without that feature refuses it.
2. Check state, availability and power provenance
In your broker's MQTT client, subscribe to dcentos/heater-01/state and dcentos/heater-01/availability, using the broker access configured above. State messages are JSON and are not retained. Availability uses retained online/offline messages and a broker Last Will; disconnect reporting depends on the broker and connection lifecycle.
With discovery enabled, the source publishes Home Assistant sensor definitions at homeassistant/<domain>/dcentrald_<mac_id>/<entity_id>/config (for example, homeassistant/sensor/dcentrald_a1b2c3/hashrate/config, where a1b2c3 illustrates the last six MAC-address hex characters). Use the energy entity for kWh and inspect Power Provenance before treating watts as a measurement. Power, BTU and efficiency can be unknown when live power is unavailable; a modeled value is not a wall-meter reading. The reported heater temperature is chip/chain temperature, not room temperature.
For a state message saved as state.json, this read-only extraction keeps the provenance beside the value:
jq '{hashrate_ghs, live_power_available, wall_watts,
energy_kwh, power_modeled, power_note}' state.json
Monitoring does not require enabling remote setpoint writes. Command entities additionally require the daemon's setter sink; For builds classified as release, the source also requires a locally provisioned non-empty command-token file. Broker authentication and topic access rules still govern who can publish. These examples do not provision tokens or send control commands.
3. Send only selected events to your JSON receiver
Replace the URL with your own receiver. This generic example selects two events forwarded by the active mining-sync bridge instead of selecting every declared event:
[webhook]
enabled = true
url = "https://receiver.example/dcentos-hook"
format = "generic"
events = ["share_milestone", "lucky_share"]
The receiver gets a JSON POST with miner, a Unix-seconds timestamp, and alert.event/alert.data. For share_milestone, the data field is accepted; for lucky_share, it is difficulty, which can be null. The miner label comes from [general].hostname; miner_name is not an accepted [webhook] key. The generic dispatcher has no configurable bearer-header field. Use an endpoint designed to accept this contract; provider-specific integrations may use their native delivery format. For these mining-sync events, the wired event-bus dispatcher uses a bounded queue and retries; delivery is best effort and events can be dropped when its queue is full. The separate daemon alert path uses its own POST loop, so these retry limits are not a blanket promise for every event producer. Notifications do not replace recovery planning or local supervision.
Webhook contracts: declared events and actual wired producers
Retained repository configuration example; check the installed configuration
(Verified (shipped config) — dcentrald.toml:172-176):
[webhook]
enabled = false
url = "https://example.com/dcentos-hook"
# events = ["share_milestone", "lucky_share"]
# miner label comes from [general].hostname; miner_name is not a valid webhook key
An empty or absent events list means all events
(webhook.rs:463-465).
| Event name | Declared meaning; emission requires a wired producer | Payload fields |
|---|---|---|
mining_started | the daemon began dispatching work | pool |
mining_stopped | clean shutdown, curtailment, or an operator stop | reason |
pool_failover | the Stratum client moved to a backup pool | from, to |
pool_disconnected | a pool connection dropped, no backup engaged yet | pool |
thermal_safety | an emergency-shutdown / dangerous-temperature event | temp_c, chain_id (0 = all) |
share_milestone | a milestone count of accepted shares | accepted |
lucky_share | a share whose achieved difficulty far exceeds the pool target | difficulty (optional) |
ota | a firmware-update lifecycle step | phase |
fan_failure | a fan stopped or fell below its safe RPM floor | rpm |
hashboard_offline | a board enumerated chips but produces no hashrate | chain_id |
thermal_restart | mining resumed after a recoverable thermal event | none |
hashrate_degraded | total hashrate stayed below the operator's floor (detection only) | observed_ghs, floor_ghs |
hashrate_recovery_exhausted | the auto-recovery ladder gave up on a degraded episode | observed_ghs, floor_ghs, attempts |
The table above describes declared event vocabulary, not proof that every variant is emitted. The production mining-sync bridge is source-traced for share_milestone and lucky_share; the separate inline alert path must be assessed separately. An older alert path,
AlertEvent (dcentrald/src/runtime/notifications.rs:395-446), emits
8 names of its own — seven of which the table above also covers, plus one it does
not: emergency_shutdown.
Two producers, one filter — and the arithmetic closes exactly
WebhookEvent declares 13 names
(dcentrald-api/src/webhook.rs:182-198). AlertEvent emits 8
(notifications.rs:434-445). They overlap on seven — fan_failure,
hashboard_offline, hashrate_degraded,
hashrate_recovery_exhausted, mining_stopped,
pool_disconnected, thermal_restart. Their union is
14, set-identical to WEBHOOK_SUPPORTED_EVENTS, the 14-name list the
API validates your events array against
(dcentrald-api/src/rest.rs:5629-5650).
So the number to configure against is 14. Only
emergency_shutdown comes from the alert path alone; only
mining_started, pool_failover, thermal_safety,
share_milestone, lucky_share and ota are declared only in the WebhookEvent vocabulary; this does not establish live emission. A configuration naming anything outside those 14 is rejected
(rest.rs:5836). Verified (in source) — the set arithmetic is
extracted by script from both enum definitions, not counted by eye.
| Event name | Declared meaning; emission requires a wired producer | Payload fields |
|---|---|---|
emergency_shutdown | the thermal loop performs an emergency shutdown | temp_c, chain_id |
One event has two spellings, and you need both
Thirteen of the fourteen names are the same string wherever you meet them. One is
not. Both producers are declared
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
(webhook.rs:94, notifications.rs:394), and serde's snake_case of the Rust
variant HashBoardOffline is hash_board_offline. But the explicit
event_name() arm returns hashboard_offline
(webhook.rs:193, notifications.rs:440,
rest.rs:5635). So:
- In your
[webhook] eventsfilter, and anywhere the API validates a name, writehashboard_offline. - In the JSON that actually arrives, the
alert.eventvalue for that one event ishash_board_offline.
If you match on the event string in a receiver, match both. Verified (in source).
| Format | Body posted |
|---|---|
generic (default) | { "miner": …, "timestamp": …, "alert": { "event": …, "data": … } } |
discord | { "content": "<one-line summary>" } |
slack | { "text": "<one-line summary>" } |
telegram | { "chat_id": …, "text": … } to https://api.telegram.org/bot<token>/sendMessage |
The three chat formats are delivered natively, with no relay or proxy in between
(webhook.rs:286-287).
Your wallet address does not leave the miner in a webhook
Stratum pool strings routinely carry the worker — often a wallet address — and the password as URL components. The dispatcher handles this at a single chokepoint, all Verified (in source):
- Redaction runs inside the dispatcher, before serialization, so no caller can
skip it (
webhook.rs:606-607); it is documented as idempotent (:210). - Pool strings are reduced to host and port: scheme dropped, then any
user:pass@, then any path or query — and the remainder is still run through a wallet-address scanner as a second pass (webhook.rs:252-275). - Free-text fields — the
mining_stoppedreason and theotaphase — are wallet-scanned too (:217,:227). - A Telegram bot token lives in the URL path by Telegram's design, so the dispatcher
logs the event name and HTTP status and never the URL (
webhook.rs:305-309).
Queued dispatcher limits and separate inline alert delivery
The following queue/retry limits apply to the event-bus dispatcher. The mining-sync bridge is source-traced for share_milestone and lucky_share. Declared variants alone do not prove emission; the separate inline AlertEvent sender performs its own POST loop without these retries. Delivery is best effort, not a supervision guarantee.
| Bound | Value | Constant |
|---|---|---|
| Per-attempt HTTP timeout | 5 s | DEFAULT_WEBHOOK_TIMEOUT |
| Retries | 2 (3 attempts total) | DEFAULT_WEBHOOK_RETRIES |
| Backoff between attempts | 500 ms, fixed | DEFAULT_WEBHOOK_RETRY_BACKOFF |
| Queue depth | 64 events | WEBHOOK_QUEUE_DEPTH |
Enqueue never blocks: a full queue drops the event and logs it at debug level
(webhook.rs:490-491, :502-520). The enqueue result is explicitly documented
as informational, with the instruction that callers "should never branch mining behaviour on
it" (:500-501). Configuration changes — enable, URL, event list — take effect
through a watch channel with no daemon restart (:526-529). With webhooks disabled or no
URL set, events are dropped before any network I/O happens at all (:596-600).
The Bitaxe side is a second implementation, not the same one
DCENT_axe carries its own Home Assistant discovery builder. The source is explicit that this is
deliberate: "This is a fresh esp-idf-runtime implementation — the TOPIC/PAYLOAD shape is shared,
the code is not" (DCENT_OS_ESP/dcentaxe/src/mqtt_ha.rs:22-25).
Verified (in source). Do not assume a behaviour transfers between them.
| Antminer (dcentrald) | Bitaxe (DCENT_axe) | |
|---|---|---|
| Hashrate unit | TH/s (mqtt.rs:783) | GH/s (mqtt_ha.rs:187) |
| Always-published entities | 13 | 9 |
| BTU/h, Efficiency, Power Provenance, Pool Connected | present | not published |
| Command entities | Fan PWM, Target Power, Space Heater | Target Power, Autotuner Mode, Space Heater |
| Target-power clamp | 100 – 6000 W (mqtt.rs:132, :135) | 5 – 200 W (mqtt_ha.rs:559, :564) |
| Target-temperature clamp | 40 – 60 °C (mqtt.rs:137, :142) | 40 – 95 °C (mqtt_ha.rs:567, :572) |
Device manufacturer | "D-Central Technologies" (mqtt.rs:763) | "D-Central" (mqtt_ha.rs:45) |
| Outbound notification vocabulary | the 14-name union above | four kinds: share_milestone, thermal, failover, ota (DCENT_OS_ESP/dcentaxe/src/notifications.rs:11-16) |
The 60 °C and 95 °C ceilings are not an inconsistency — different silicon, different thermal ladders. They are listed side by side precisely so nobody merges them into one number.
The two notification vocabularies are also not disjoint, and it would be easy to assume they are:
share_milestone and ota appear in both, while thermal and
failover are ESP-only spellings with no exact counterpart in the Antminer union (the
Antminer names for the same territory are thermal_safety / thermal_restart
and pool_failover). If you are writing one receiver for both platforms, treat the two
name sets as overlapping-but-different, not as one vocabulary. Verified (in source)
— the intersection is computed by script from both enum definitions.
Read this as source, not as a datasheet. These values are what the firmware is written to do. We have not confirmed them on a miner.
What this page does not tell you
- Whether any of it works against a real broker. No bench verification. This is a reading of the source and of the shipped configuration file.
- The REST endpoint schemas. The repository publishes no OpenAPI specification. We
name
GET /api/mqtt/statusonly because the shipped configuration file names it (dcentrald.toml:159); we do not print a route table we have not resolved against the router. See API & ports for what is published. - The full thermal ladder. Two layers of the source name different hot/dangerous thresholds, so we state only the clamp that is actually asserted: a remote Home Assistant setpoint cannot exceed 60 °C on an Antminer.
Which control boards this has evidence on is a separate question from what the firmware is written to do; see hardware status for the per-lane picture. Nearby: all documentation.
Source of record: the public GPL-3.0 release tree at
github.com/DCentralTech/DCENT_OS,
commit 6f61603 (2026-08-16). Line references are to that commit. Code moves; if a line
number has drifted, the file and the symbol name still find it. Reviewed 2026-08-19.
Companion tool — DCENT_Toolbox: DCENT_Toolbox command reference.
