Skip to content
Small team, full backlog, zero orders dropped. Support replies are slower than we’d like. Read our status update → Zero orders dropped. Status → 📬 Check your spam folder — most of our replies land there. We do answer. Status update → 📬 Check your spam folder. Status →

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.toml that 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.

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).

Entities published on every connect — 13. Verified (in source) — dcentrald-api/src/mqtt.rs:744-984
HA domainObjectEntity nameUnitDefined at
sensorhashrateHashrateTH/s:779-786
sensortemperatureTemperature°C:787-798
sensorpowerPowerW:799-806
sensorbtuBTU/hBTU/h:807-814
sensorfan_rpmFan RPMRPM:815-822
sensorefficiencyEfficiencyJ/TH:823-830
sensoracceptedAccepted Shares—:831-838
sensorrejectedRejected Shares—:839-846
sensoruptimeUptimes:847-854
sensorenergyEnergykWh:907-923
sensorpower_provenancePower Provenance— (diagnostic):930-942
binary_sensorminingMining Active—:946-952
binary_sensorpoolPool 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 none unless the daemon has published live_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_template carrying power_source, power_source_detail, power_modeled, power_calibrated and power_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.

Operator-writable entities, published only when commands are enabled. Verified (in source) — mqtt.rs:990-1055; constants mqtt.rs:124-142; topics mqtt.rs:207-227
HA domainEntityCommand topicRange enforced
numberFan PWM<prefix>/fan_pwm/set0 – 30 %
numberTarget Power<prefix>/target_watts/set100 – 6000 W
climateSpace Heater<prefix>/target_temp/set40 – 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_MAX by 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 /set topic 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).

Declared WebhookEvent variants — 13. Verified (in source) — dcentrald-api/src/webhook.rs:182-198, payload fields at :95-176
Event nameDeclared meaning; emission requires a wired producerPayload fields
mining_startedthe daemon began dispatching workpool
mining_stoppedclean shutdown, curtailment, or an operator stopreason
pool_failoverthe Stratum client moved to a backup poolfrom, to
pool_disconnecteda pool connection dropped, no backup engaged yetpool
thermal_safetyan emergency-shutdown / dangerous-temperature eventtemp_c, chain_id (0 = all)
share_milestonea milestone count of accepted sharesaccepted
lucky_sharea share whose achieved difficulty far exceeds the pool targetdifficulty (optional)
otaa firmware-update lifecycle stepphase
fan_failurea fan stopped or fell below its safe RPM floorrpm
hashboard_offlinea board enumerated chips but produces no hashratechain_id
thermal_restartmining resumed after a recoverable thermal eventnone
hashrate_degradedtotal hashrate stayed below the operator's floor (detection only)observed_ghs, floor_ghs
hashrate_recovery_exhaustedthe auto-recovery ladder gave up on a degraded episodeobserved_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.

The eighth alert-path event, absent from the table above. Verified (in source) — notifications.rs:395-399, :436
Event nameDeclared meaning; emission requires a wired producerPayload fields
emergency_shutdownthe thermal loop performs an emergency shutdowntemp_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] events filter, and anywhere the API validates a name, write hashboard_offline.
  • In the JSON that actually arrives, the alert.event value for that one event is hash_board_offline.

If you match on the event string in a receiver, match both. Verified (in source).

Delivery formats. Verified (in source) — webhook.rs:288-312, generic envelope at :628-637
FormatBody 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_stopped reason and the ota phase — 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.

Delivery bounds. Verified (in source) — webhook.rs:61-76
BoundValueConstant
Per-attempt HTTP timeout5 sDEFAULT_WEBHOOK_TIMEOUT
Retries2 (3 attempts total)DEFAULT_WEBHOOK_RETRIES
Backoff between attempts500 ms, fixedDEFAULT_WEBHOOK_RETRY_BACKOFF
Queue depth64 eventsWEBHOOK_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.

Where the two platforms differ. Verified (in source) — Antminer mqtt.rs, Bitaxe mqtt_ha.rs
 Antminer (dcentrald)Bitaxe (DCENT_axe)
Hashrate unitTH/s (mqtt.rs:783)GH/s (mqtt_ha.rs:187)
Always-published entities139
BTU/h, Efficiency, Power Provenance, Pool Connectedpresentnot published
Command entitiesFan PWM, Target Power, Space HeaterTarget Power, Autotuner Mode, Space Heater
Target-power clamp100 – 6000 W (mqtt.rs:132, :135)5 – 200 W (mqtt_ha.rs:559, :564)
Target-temperature clamp40 – 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 vocabularythe 14-name union abovefour 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/status only 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.