No cloud, no account, no power-consumption guessing: the machine already says what it is doing on a service bus behind the front panel. This is the firmware I wrote for an existing interface board, building on the reverse-engineering work of others, and the openHAB configuration behind it.
Where the idea came from
On holiday I watched the 39C3 talk Hacking washing machines by Severin von Wnuck-Lipski and Hajo Noerenberg, and I thought the idea was brilliant. B/S/H appliances (Bosch, Siemens, Neff, Gaggenau) carry an internal service bus that the service technician’s diagnostic tool talks to. Everything the machine knows travels over it: the selected programme, the phase it is in, the remaining time, the residual moisture in the drum, the door, the fault register.
Their work is documented in hn/bsh-home-appliances, together with a growing collection of decoded machines. That is where this started.
My machine is a Siemens iQ890 heat-pump dryer (WT47Y781). It now reports thirteen values into openHAB over MQTT, from a device that reads the bus.
What the bus looks like
Three wires: GND, VBUS and DATA. DATA is a single 5 V TTL line that every board in the appliance shares, 8N1, usually at 9600 baud and on newer machines at 19200 or 38400. VBUS is what powers the adapter, and its voltage depends on the appliance: 9 V in washing machines, 13.5 V in dishwashers, 5 V in extractor hoods. The connector is a coded three-pin RAST 2.5, so it cannot be plugged in the wrong way round.
A frame on the wire is short. A length byte, a destination byte, the data (whose first two bytes are the command), a 16-bit checksum, and then a single byte back from the node that was addressed, acknowledging it or refusing it:
[len] [dest] [cmd hi] [cmd lo] [payload ...] [crc hi] [crc lo] <- [ack]
| | | | | |
| | | | | +-- the addressed node's answer
| | | | +-- checksum over the frame
| | | +-- what the command carries: 0C 00
| | +-- command 0x1000
| +-- destination 0x21: node 2, subsystem 1
+-- how many bytes follow
The destination splits into a node and a subsystem, which is why an address is written as 21.1000: destination 0x21, command 0x1000. Frames are not delimited by a start byte; the parser resyncs on 2.5 byte times of silence between them.
In the adapter’s monitor the same frame reads like this. The first two columns are the adapter’s own bookkeeping, not something the bus sends:
14:56:00 450.841s 21.1000 0C00
| | | +-- payload bytes
| | +-- destination . command
| +-- uptime when the frame arrived
+-- wall clock, added by the adapter
Nothing has to be asked for. The appliance’s boards talk to each other, on their own schedule, and a listener on the line simply hears it. That is why the whole thing can stay strictly read-only: the adapter’s UART transmit path is never driven, and the build fails if anyone ever adds a write call.
Before you open anything. This is inside a mains-powered appliance. Unplug it, and if you are not comfortable working in there, do not. It voids your warranty, and it is entirely at your own risk. I am a hobbyist with no connection whatsoever to B/S/H.
The hardware
The bus is not 3.3 V logic, so an ESP32 needs an open-drain level shifter between itself and the appliance, plus a converter for the supply rail. You do not have to design that yourself: several people have built adapters and share their boards, listed in the D-Bus adapter section of the upstream project. They range from a compact ESP32-C6 board to one that fits entirely inside a connector.
I did not build anything. I ordered a ready-made board from Bouni, who offers assembled ones, and had the RAST connectors come with it, which is worth saying because the connectors are not part of the board. No soldering, and it arrived ready to plug in.
On my dryer the connector sits behind the front panel: two screws at the back, slide the lid off, then the front comes away, you just have to remove a few extra screws to access the back of the display assembly. If you are unsure which pin is which, a multimeter tells you: measure between the candidates to find the supply rail and ground, and the remaining pin is data.
The bus on my dryer: the circled three-pin connector between two wider neighbours. On my machine it was free and the adapter went straight in. If yours is occupied, look over the board for a free header: the bus is a shared line that every board listens to, so an adapter on any spare one hears everything and displaces nobody.
Baud rates differ between models, so the firmware sweeps the plausible rates at boot and locks onto the one that produces valid frames. You do not have to know it in advance.
The adapter firmware
The upstream project runs ESPHome. I wanted the appliance’s meaning to live in data rather than in a YAML file per machine, so I wrote an ESP-IDF firmware where a machine is described by a JSON profile: which address carries which value, how to pull it out of the payload, and how to name it.
- Read-only by construction. No transmit path, enforced in CI.
- Runtime profiles. 22 machines ship in the binary, converted from the upstream ESPHome configurations; you can upload your own through the web interface without rebuilding.
- MQTT with Home Assistant discovery, retained state topics, and one availability topic on the last will.
- A web interface for setup, a live frame monitor, traffic download, OTA updates with rollback.
- Web installer. Flash it from the browser, no toolchain: bruestel.github.io/bsh-dbus-adapter
The adapter’s own page while a load runs. Eleven of the dryer’s fourteen values are current; the pale tiles are the ones the machine has not reported yet, which the page says rather than filling in. This is also what the MQTT topics carry.
If your model is not in the list, the monitor shows you the unknown addresses live while you turn the dial, which is exactly how the dryer profile got written.
The live monitor: every frame newest first, with the byte the addressed node answered and what the active profile makes of it. The grey row is an address nobody has decoded yet, which is where a new profile starts.
Writing that decoding down: which byte, how to read it, what the codes mean, and what to do with a code that is not in the list. The line underneath runs the frame currently on the bus through the rule while you type.
You do not have to stare at that table yourself, and this is the part I would not have expected to work as well as it does. The monitor lets you download the traffic as a log, so record a few sessions while you use the machine and write down what the display said and when. Hand the log and those notes to an AI agent and ask it which address carries which value: correlating a hundred frames against a handful of observations is exactly the kind of tedious work it is good at, and it comes back with candidate byte offsets and code tables in minutes rather than an evening. Then check its answers in the profile editor, which evaluates every rule live against the bus, and keep the ones that hold. My dryer’s profile came together this way. Keep your recordings, though: they are what lets you check a decoding decision later instead of believing it.
Source and documentation: github.com/bruestel/bsh-dbus-adapter
What arrives in openHAB
The adapter publishes under bshdbus/dryer/state/<id>, one retained topic per value:
| Topic | Type | Example |
|---|---|---|
status |
string | Running, Finished, Paused, Off |
ready |
string | Start pending, Started, Ended |
phase |
string | Drying, Final phase, Finished |
program |
string | Cotton, Synthetics, Jeans |
drying_goal |
string | Cupboard dry, Iron dry |
fine_adjust |
string | Cupboard dry+ |
door |
string | Open, Closed |
fault |
string | None, Lint filter, Water tank |
time_remaining |
number | 42 (minutes) |
anti_crease_time |
number | 18 (minutes) |
spin_speed |
number | 1400 (rpm, from the washer) |
residual_moisture |
number | 17 (%) |
low_heat |
switch | On / Off (gentle drying) |
Two conventions matter for the openHAB side. A value the adapter does not know is published as an empty payload, which arrives as UNDEF. After a restart it genuinely does not know the selected programme until something happens at the machine. And a byte it can read but cannot name comes through as Unknown (7) rather than a guess.
The thing
One generic MQTT thing, thirteen channels. The interesting part is the availability topic: the adapter draws its power from the appliance bus, so switching the dryer off kills the adapter, the broker publishes the last will, and the thing goes OFFLINE by itself. Nothing polls anything.
Thing mqtt:topic:bshdbus:dryer "Tumble dryer" (mqtt:broker:central)
[ availabilityTopic="bshdbus/dryer/status",
payloadAvailable="online", payloadNotAvailable="offline" ] {
Channels:
Type string : status "Status" [ stateTopic = "bshdbus/dryer/state/status" ]
Type string : programme_control "Programme control"[ stateTopic = "bshdbus/dryer/state/ready" ]
Type string : phase "Phase" [ stateTopic = "bshdbus/dryer/state/phase" ]
Type string : program "Programme" [ stateTopic = "bshdbus/dryer/state/program" ]
Type string : drying_goal "Drying target" [ stateTopic = "bshdbus/dryer/state/drying_goal" ]
Type string : fine_adjust "Dryness level" [ stateTopic = "bshdbus/dryer/state/fine_adjust" ]
Type string : door "Door" [ stateTopic = "bshdbus/dryer/state/door" ]
Type string : fault "Fault" [ stateTopic = "bshdbus/dryer/state/fault" ]
Type number : time_remaining "Remaining time" [ stateTopic = "bshdbus/dryer/state/time_remaining", unit = "min" ]
Type number : anti_crease_time "Anti-crease" [ stateTopic = "bshdbus/dryer/state/anti_crease_time", unit = "min" ]
Type number : spin_speed "Spin speed" [ stateTopic = "bshdbus/dryer/state/spin_speed" ]
Type number : residual_moisture "Residual moisture"[ stateTopic = "bshdbus/dryer/state/residual_moisture" ]
Type switch : gentle_drying "Gentle drying" [ stateTopic = "bshdbus/dryer/state/low_heat", on = "On", off = "Off" ]
}
The items
Straightforward, with semantic tags so the values also show up in the model. Dryer_Online deliberately has no channel. It is fed from the thing status by a rule, so that the sitemap can hide rows when the machine is away.
Switch Dryer_Online "Online[]" <switch> (gLaundryRoomDryer, gPersistLastState, gPanel) ["Switch"]
String Dryer_OperationState "Dryer status [%s]" <dryer> (gLaundryRoomDryer, gPersistLastState, gPanel) ["Status"] {channel="mqtt:topic:bshdbus:dryer:status"}
String Dryer_ProgrammeControl "Programme control [%s]" <status> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:programme_control"}
String Dryer_Phase "Phase [%s]" <status> (gLaundryRoomDryer, gPersistLastState, gPanel) ["Status"] {channel="mqtt:topic:bshdbus:dryer:phase"}
String Dryer_Program "Programme [%s]" <dryer> (gLaundryRoomDryer, gPersistLastState, gPanel) ["Status"] {channel="mqtt:topic:bshdbus:dryer:program"}
String Dryer_DryingGoal "Drying target [%s]" <dryer> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:drying_goal"}
String Dryer_FineAdjust "Dryness level [%s]" <dryer> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:fine_adjust"}
String Dryer_DoorState "Dryer door [%s]" <contact> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:door"}
String Dryer_Fault "Fault [%s]" <error> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:fault"}
Number:Time Dryer_RemainingProgramTime "Remaining time [%.0f min]" <time> (gLaundryRoomDryer, gPersistLastState, gPanel) ["Status","Duration"] {channel="mqtt:topic:bshdbus:dryer:time_remaining"}
Number:Time Dryer_AntiCreaseTime "Anti-crease [%.0f min]" <time> (gLaundryRoomDryer, gPersistLastState) ["Status","Duration"] {channel="mqtt:topic:bshdbus:dryer:anti_crease_time"}
Number Dryer_SpinSpeed "Spin speed [%.0f rpm]" <qualityofservice> (gLaundryRoomDryer, gPersistLastState) ["Status"] {channel="mqtt:topic:bshdbus:dryer:spin_speed"}
Number Dryer_ResidualMoisture "Residual moisture [%.0f %%]" <humidity> (gLaundryRoomDryer, gPersistLastState) ["Measurement","Humidity"] {channel="mqtt:topic:bshdbus:dryer:residual_moisture"}
Switch Dryer_GentleDrying "Gentle drying[]" <temperature> (gLaundryRoomDryer, gPersistLastState) ["Switch"] {channel="mqtt:topic:bshdbus:dryer:gentle_drying"}
A note on language. My own configuration is German, and the labels above are translated for this post. Worth knowing if you build a German machine: the adapter’s own state words are English, but the programme and dryness names come out of the appliance in its display language. Mine sends
BĂĽgeltrocken erreicht. If you want a single language in the UI, that is what aMAPtransform is for, and remember to list the values you are not changing as identities too, otherwise MAP falls through and logs a warning on every update.
The sitemap
Every row is conditional. The adapter does not invent values, so instead of printing a placeholder the sitemap simply takes the row away when there is nothing to say:
Text item=Dryer_Phase label="Dryer" staticIcon="dryer-1" {
Default item=Dryer_Online
Text item=Dryer_Program label="Programme [%s]" icon="dryer"
visibility=[Dryer_Online==ON AND Dryer_Program!=UNDEF AND Dryer_Program!=NULL AND Dryer_Program!=""]
Text item=Dryer_DryingGoal label="Drying target [%s]" icon="dryer"
visibility=[Dryer_Online==ON AND Dryer_DryingGoal!=UNDEF AND Dryer_DryingGoal!=NULL AND Dryer_DryingGoal!=""]
Default item=Dryer_GentleDrying
visibility=[Dryer_Online==ON AND Dryer_GentleDrying!=UNDEF AND Dryer_GentleDrying!=NULL]
Text item=Dryer_Fault icon="error" label="Maintenance [%s]"
visibility=[Dryer_Online==ON AND Dryer_Fault!=UNDEF AND Dryer_Fault!="" AND Dryer_Fault!="None"]
Text item=Dryer_OperationState icon="status" label="Status [%s]"
visibility=[Dryer_Online==ON AND Dryer_OperationState!=UNDEF AND Dryer_OperationState!=""]
Text item=Dryer_Phase icon="status" label="Phase [%s]"
visibility=[Dryer_Online==ON AND Dryer_Phase!=UNDEF AND Dryer_Phase!=""]
Default item=Dryer_DoorState label="Door"
visibility=[Dryer_Online==ON AND Dryer_DoorState!=UNDEF AND Dryer_DoorState!=""]
Text item=Dryer_RemainingProgramTime label="Remaining time [%.0f min]" staticIcon="time"
visibility=[Dryer_Online==ON AND Dryer_RemainingProgramTime!=UNDEF AND Dryer_RemainingProgramTime!=NULL]
Text item=Dryer_AntiCreaseTime label="Anti-crease [%.0f min]" staticIcon="time"
visibility=[Dryer_Online==ON AND Dryer_AntiCreaseTime!=UNDEF AND Dryer_AntiCreaseTime!=NULL]
Text item=Dryer_SpinSpeed label="Spin speed [%.0f rpm]" icon="qualityofservice"
visibility=[Dryer_Online==ON AND Dryer_SpinSpeed!=UNDEF AND Dryer_SpinSpeed!=NULL]
Text item=Dryer_ResidualMoisture label="Residual moisture [%.0f %%]" icon="humidity"
visibility=[Dryer_Online==ON AND Dryer_ResidualMoisture!=UNDEF AND Dryer_ResidualMoisture!=NULL]
}
The panel when the notification arrived, in German here. The status still says Endphase while the phase already says fertig, which is why the rule triggers on the phase.
The rules
Three of them. One mirrors the thing status into Dryer_Online, one resets the values when the appliance disappears, and one sends the notification.
rule "Dryer thing online status check"
when
Thing "mqtt:topic:bshdbus:dryer" changed
then
if (getThingStatusInfo("mqtt:topic:bshdbus:dryer").getStatus().toString == "ONLINE") {
Dryer_Online.postUpdate(ON)
} else {
Dryer_Online.postUpdate(OFF)
}
end
rule "Dryer set state offline"
when
Item Dryer_Online changed to OFF
then
// The adapter is powered from the bus: if it is gone, the machine is off.
Dryer_OperationState.postUpdate("Off")
Dryer_RemainingProgramTime.postUpdate(UNDEF)
Dryer_Phase.postUpdate("Ready")
Dryer_ProgrammeControl.postUpdate("Ready")
end
rule "Dryer notification"
when
Item Dryer_Phase changed to "Finished"
then
val actions = getActions("pushover", "pushover:pushover-account:account")
actions.sendMessage("I am done!", "Dryer", null, null, null, null, null, null, null)
Dryer_RemainingProgramTime.postUpdate(UNDEF)
end
Links
- Hacking washing machines (39C3) - the talk that started it, by Severin von Wnuck-Lipski and Hajo Noerenberg.
- hn/bsh-home-appliances - protocol documentation, ESPHome configurations, and the machine list.
- bruestel/bsh-dbus-adapter - the firmware described here, with wiring notes and the profile format.
- Web installer - flash an ESP32 from the browser, no toolchain needed.
- Adapter boards - the list of designs people share, including Bouni’s, which is the one I use.
Happy to answer questions, and if you have a B/S/H machine that is not decoded yet I would be glad to help work out its profile. The frame monitor makes that a lot less painful than it sounds.




