BLANCO CHOICE.All in openHAB: an ESP32 BLE-to-MQTT gateway

The BLANCO CHOICE.All is a drinking water system under the sink: filtered still, medium and sparkling water, plus boiling water, from one tap. It talks to exactly one thing: the BLANCO UNIT App, over Bluetooth Low Energy. No cloud, no API, no binding, and nothing for openHAB to bind to.

So I put an ESP32 next to it that speaks BLE to the unit and MQTT to openHAB. Filter and CO2 levels become Items, and the sitemap gets nine buttons for the combinations of glass size and carbonation my family actually uses.

(My own installation runs in German, hence the labels in the screenshot. The examples below are in English.)


What you’re building

BLANCO CHOICE.All  ──BLE──►  ESP32 gateway  ──MQTT──►  broker  ──►  openHAB
   (under the sink)           (a few euro)                          Things / Items / Rules

The gateway is blanco-esp32. It keeps a BLE connection to the unit, authenticates with the unit’s pairing code, polls the status every 15 minutes and publishes it as JSON. Dispensing works the other way round: publish an amount and a carbonation level, and water comes out.

The heavy lifting, reverse engineering the BLE protocol, is not mine. That is @Nailik’s work in blanco_unit; my part is an ESP32 firmware that does not need a computer running next to the sink.

Before you start

  • An ESP32 or ESP32-S3. Any board will do. I use an ESP32-S3-DevKitC-1; the classic ESP32 is tested too.
  • Your unit’s five-digit pairing code. It is the same one the BLANCO UNIT App asks for, printed on the type plate behind the CO2 cartridge. Open the front flap of the under-sink unit, the plate sits next to the cylinder and the filter. It cannot be read from the display.
  • Connectivity switched on at the unit. On the under-sink display, work through the menu until the connectivity mode appears and set it to CON ON. CON OFF means the BLE module is off and nothing will find it.
  • An MQTT broker and the MQTT binding, which you almost certainly already have.

The unit accepts one BLE connection at a time. While the gateway holds it, the BLANCO UNIT App will not connect, and vice versa. Close the app before you start.

Step 1: Flash the gateway

There is a web installer: plug the board in, open the page in Chrome or Edge, pick your chip, done. No toolchain, no clone.

If you prefer to build it yourself:

git clone https://github.com/bruestel/blanco-esp32.git
cd blanco-esp32
pio run -e esp32s3 -t upload      # or: -e esp32dev

On an ESP32-S3-DevKitC-1, use the port labelled UART, not the one labelled USB. The native USB port can leave the chip sitting in its ROM bootloader after a reset, which looks exactly like a dead board.

Step 2: Set it up in the browser

Nothing is compiled in. On first boot the gateway opens an access point called BlancoGW-XXXXXX, password blancogw. Join it and the setup page appears at http://192.168.4.1/.

Fill in WiFi and your broker, then scan for BLE devices. Your unit shows up as CHOICE.All-… and is sorted to the top. Finally enter the pairing code. Press Check connection before saving: it runs the whole BLE path and tells you which step fails, so a mistyped code shows up here rather than as a gateway that reboots every few seconds.

The topic prefix defaults to blanco and the device name to the unit’s MAC address without colons, which is what the examples below use. Both are configurable.

To get back in later: hold BOOT for five seconds, or publish to blanco/<name>/command/setup.

Step 3: The Thing

Two status channels and two command channels are enough for everything in the screenshot. The status topic carries one JSON document, so each channel picks its value out with JSONPATH:

// things/blanco.things
Thing mqtt:topic:central:blanco "Blanco" (mqtt:broker:central) @ "Kitchen" [
    availabilityTopic="blanco/aabbccddeeff/available",
    payloadAvailable="online",
    payloadNotAvailable="offline"
] {
    Channels:
        // Status
        Type number : filter_rest "Filter Rest" [ stateTopic="blanco/aabbccddeeff/status", transformationPattern="JSONPATH:$.status.filter_rest" ]
        Type number : co2_rest    "CO2 Rest"    [ stateTopic="blanco/aabbccddeeff/status", transformationPattern="JSONPATH:$.status.co2_rest" ]

        // Commands
        Type string : dispense    "Dispense"    [ commandTopic="blanco/aabbccddeeff/command/dispense" ]
        Type switch : refresh     "Refresh"     [ commandTopic="blanco/aabbccddeeff/command/refresh", on="true", off="true" ]
}

Replace aabbccddeeff with your unit’s MAC in lowercase without colons, the same value the setup page filled in for you.

The availability topic is worth wiring up: it is a retained last will, so openHAB marks the Thing offline the moment the gateway drops off, rather than showing stale values forever.

Step 4: Items

// items/blanco.items
Group                gBlanco             "Blanco"

Number:Dimensionless Blanco_FilterRest   "Filter remaining [%d %%]"   <battery>         (gBlanco, gPersist)     {channel="mqtt:topic:central:blanco:filter_rest", unit="%"}
Number:Dimensionless Blanco_CO2Rest      "CO2 remaining [%d %%]"      <carbondioxide>   (gBlanco, gPersist)     {channel="mqtt:topic:central:blanco:co2_rest", unit="%"}

String               Blanco_Dispense     "Dispense command"                             (gBlanco, gPersistNone) {channel="mqtt:topic:central:blanco:dispense"}
Switch               Blanco_Refresh      "Refresh"                                      (gBlanco, gPersistNone) {channel="mqtt:topic:central:blanco:refresh"}

Step 5: The sitemap, without a single rule

This is the part I like. The dispense command is a small JSON document:

{ "amount": 200, "intensity": 1 }

amount is millilitres (50–2000), intensity is 1 still, 2 medium, 3 sparkling. Since the String Item passes its command straight through to the MQTT topic, the JSON can live in the sitemap mappings. No rule builds it, no transformation is involved:

// sitemaps/home.sitemap
Text item=Blanco_CO2Rest label="Blanco faucet []" staticIcon="faucet_kitchen" {
    Default item=Blanco_FilterRest staticIcon="cleaning"
    Default item=Blanco_CO2Rest
    Switch item=Blanco_Dispense staticIcon="faucet_kitchen" label="Dispense (still) []"     mappings=['{"amount":200,"intensity":1}'="Small glass", '{"amount":300,"intensity":1}'="Large glass", '{"amount":1000,"intensity":1}'="Bottle"]
    Switch item=Blanco_Dispense staticIcon="faucet_kitchen" label="Dispense (medium) []"    mappings=['{"amount":200,"intensity":2}'="Small glass", '{"amount":300,"intensity":2}'="Large glass", '{"amount":1000,"intensity":2}'="Bottle"]
    Switch item=Blanco_Dispense staticIcon="faucet_kitchen" label="Dispense (sparkling) []" mappings=['{"amount":200,"intensity":3}'="Small glass", '{"amount":300,"intensity":3}'="Large glass", '{"amount":1000,"intensity":3}'="Bottle"]
}

Note the single quotes around each mapping value: the JSON contains double quotes, so the mapping has to be quoted the other way round. Three intensities times three sizes gives the nine buttons in the screenshot.

Step 6: Rules

Only one rule is actually required, and it exists for a UI reason: a Switch with mappings sends a command only when the value changes. Without resetting the Item, pressing “Small glass” twice in a row does nothing the second time.

// rules/blanco.rules
rule "Reset Blanco Dispense"
when
    Item Blanco_Dispense received command
then
    if (receivedCommand != NULL) {
        Blanco_Dispense.postUpdate(NULL)
    }
end

The other two are the reason I built this in the first place, because the unit shows its levels on a display nobody looks at:

rule "Blanco water filter empty"
when
    Item Blanco_FilterRest changed
then
    if (Blanco_FilterRest.state instanceof Number) {
        val rest = (Blanco_FilterRest.state as Number).intValue
        if (rest == 0) {
            logInfo("Blanco", "Water filter at 0%, needs replacing")
            val actions = getActions("pushover", "pushover:pushover-account:account")
            actions.sendMessage("The water filter is empty (0%), please replace it", "💧 Blanco")
        }
    }
end

The same again for Blanco_CO2Rest, because a CO2 cylinder that runs out mid-party is the other thing worth a push notification. Both levels are percentages, so warning at 10 % instead of 0 % is a one-character change.

What else the gateway publishes

Topic Direction Contents
blanco/<name>/available → openHAB online / offline, retained, last will
blanco/<name>/status → openHAB full JSON, retained, every 15 min
blanco/<name>/command/result → openHAB success / failed after dispensing
blanco/<name>/command/dispense → gateway {"amount":200,"intensity":1}
blanco/<name>/command/refresh → gateway poll now, any payload
blanco/<name>/command/reboot → gateway restart
blanco/<name>/command/setup → gateway open the setup portal for three minutes

The status document has more in it than the two levels I use: boiler and compressor temperatures, an error bit field, the serial number, firmware versions of all three controllers, and the gateway’s own version and hostname:

{
  "device":  { "device_name": "CHOICE.All-128", "serial_number": "…", "firmware": { "main": "…", "comm": "…", "elec": "…" } },
  "gateway": { "version": "0.9.0", "hostname": "blanco-gw-a1b2c3" },
  "status":  { "filter_rest": 92, "co2_rest": 10, "temp_boiler_1": 7, "temp_boiler_2": 90, "temp_compressor": 35, "error_bits": 0 },
  "last_update": "3600s uptime"
}

Adding another channel is one JSONPATH line.

Links

Happy to answer questions. And if someone gets it working with the drink.soda EVOL-S Pro, which uses the same app, I would like to hear about it.

3 Likes