Grundfos SCALA1 Pump Controller - esp home

Grundfos SCALA1 in Home Assistant via ESPHome (Bluetooth) – status + enable/disable

I’ve got my Grundfos SCALA1 booster pump working in Home Assistant over its built-in Bluetooth, using an ESPHome device (an M5Stack AtomS3 that’s also my Bluetooth proxy). No cloud, no Grundfos GO app needed once it’s paired.

What you get in Home Assistant

  • Pump running – on while the motor is actually pumping
  • Water demand – pressure low / water being drawn (stays on while a tap is open even if the pump is disabled – handy for spotting a tap left running or a leak)
  • Pump enabled (switch) – on = automatic (runs when pressure drops), off = disabled (won’t run at all). Reflects the pump’s real state.
  • Pump status bytes – diagnostic, the raw status values

The key discovery: the SCALA1 drops any Bluetooth connection that isn’t bonded and encrypted. Pairing only works while the pump’s blue wireless button is pressed, and Home Assistant’s own Bluetooth/proxy pairing path gets refused – but an ESP32 that requests encryption itself on connect works. ESPHome’s alpha3 component (for the ALPHA3 circulator) does exactly that, so this config uses it purely for its encryption step.

The pump talks Grundfos GENIbus over BLE (same service/characteristic as the ALPHA3: fe5d / 859cffd1-036e-432a-aa28-1a0085b87ba9).

Setup

  1. Save the package below as esphome/packages/scala1.yaml (a subfolder stops the ESPHome dashboard listing it as a device).

  2. Put your pump’s Bluetooth MAC address in pump_mac.

  3. Add to your ESP32’s config (ESP-IDF framework):

    packages:
      scala1: !include packages/scala1.yaml
    
  4. Install, then press the pump’s blue wireless button – the light should stay lit once bonded.

  5. Only one Bluetooth connection is allowed: close the Grundfos GO app (or turn off the “Pump Bluetooth link” switch to use the app), and don’t run other Grundfos BLE integrations at the same time.

It works alongside bluetooth_proxy – the package raises esp32_ble: max_connections to 4 so the proxy keeps its 3 slots.

Package

# Grundfos SCALA1 package - add to an existing ESPHome Bluetooth proxy config:
#
#   packages:
#     scala1: !include packages/scala1.yaml
#
# Uses one Bluetooth connection slot of its own.
# FIRST-TIME PAIRING: after flashing, press the pump's blue wireless button.
# Disable the grundfos_bluetooth custom integration in Home Assistant - the pump
# only accepts one connection at a time.

substitutions:
  pump_mac: "XX:XX:XX:XX:XX:XX"   # your pump's Bluetooth MAC address
  geni_service: "fe5d"
  geni_char: "859cffd1-036e-432a-aa28-1a0085b87ba9"

# Quieten logs: the ALPHA3 component (kept only for encryption) warns about every
# SCALA1 reply it doesn't recognise; raw GENI replies only show at debug level.
# Change geni to DEBUG if you need to see the raw replies again.
logger:
  logs:
    alpha3: ERROR
    geni: INFO

# The Bluetooth proxy uses 3 connection slots; the pump link needs a 4th.
# (If your main config already sets esp32_ble max_connections, make it at least 4.)
esp32_ble:
  max_connections: 4

ble_client:
  - mac_address: ${pump_mac}
    id: scala
    on_connect:
      then:
        - logger.log: "Connected to SCALA1"
    on_disconnect:
      then:
        - logger.log: "Disconnected from SCALA1"

sensor:
  # ESPHome's ALPHA3 component is used ONLY for its connect behaviour: it asks
  # the pump for an encrypted (bonded) link as soon as it connects. Without this
  # the SCALA1 hangs up after ~2 s. No sensors and no polling, so it sends nothing.
  - platform: alpha3
    ble_client_id: scala
    update_interval: never

  # Receives every GENIbus reply from the pump and decodes the status object.
  - platform: ble_client
    type: characteristic
    ble_client_id: scala
    name: "GENI raw"
    internal: true
    service_uuid: ${geni_service}
    characteristic_uuid: ${geni_char}
    notify: true
    update_interval: never
    lambda: |-
      ESP_LOGD("geni", "RX (%u bytes) %s", (unsigned) x.size(), format_hex_pretty(x).c_str());
      // Status reply to the 0x5B request: 24 LE F8 E7 0A .. .. .. A4 03 00 00 0B <S1> <S2> ...
      // Observed:
      //   S1 bit 0x04 = disabled (STOP command)
      //   S1 bit 0x40 = demand (pressure low / water drawn) - stays set while disabled
      //   S2 = 0B running, 0A running down, 08 stopped  -> bits 0x03 = motor running
      if (x.size() >= 15 && x[0] == 0x24 && x[4] == 0x0A && x[8] == 0xA4) {
        uint8_t s1 = x[13], s2 = x[14];
        id(pump_running).publish_state((s2 & 0x03) != 0);
        id(pump_demand).publish_state((s1 & 0x40) != 0);
        id(pump_enabled).publish_state((s1 & 0x04) == 0);
        char buf[8];
        snprintf(buf, sizeof(buf), "%02X %02X", s1, s2);
        if (id(pump_status_bytes).state != buf) {
          ESP_LOGI("geni", "Status bytes changed: %s", buf);
          id(pump_status_bytes).publish_state(buf);
        }
      }
      return NAN;

# Poll the pump's status object every 10 s (request taken from the Grundfos app).
interval:
  - interval: 10s
    then:
      - ble_client.ble_write:
          id: scala
          service_uuid: ${geni_service}
          characteristic_uuid: ${geni_char}
          value: [0x27, 0x07, 0xE7, 0xF8, 0x0A, 0x03, 0x5B, 0x00, 0xA4, 0x12, 0xA3]

binary_sensor:
  - platform: template
    id: pump_running
    name: "Pump running"
    device_class: running
  - platform: template
    id: pump_demand
    name: "Water demand"
    icon: mdi:water-alert
  - platform: template
    id: pump_enabled
    name: "Pump enabled state"
    internal: true

text_sensor:
  # Raw status bytes, to confirm the decoding (diagnostic)
  - platform: template
    id: pump_status_bytes
    name: "Pump status bytes"
    entity_category: diagnostic

switch:
  # Turn off to free the pump for the Grundfos GO app
  - platform: ble_client
    ble_client_id: scala
    name: "Pump Bluetooth link"

  # On  = enabled: runs automatically when pressure is low (GENIbus START, id 6).
  # Off = disabled: will not run even if pressure drops (GENIbus STOP, id 5).
  - platform: template
    name: "Pump enabled"
    icon: mdi:water-pump
    lambda: |-
      if (!id(pump_enabled).has_state()) return {};
      return id(pump_enabled).state;
    turn_on_action:
      - ble_client.ble_write:
          id: scala
          service_uuid: ${geni_service}
          characteristic_uuid: ${geni_char}
          value: [0x27, 0x05, 0xE7, 0xF8, 0x03, 0x81, 0x06, 0xE5, 0x87]
    turn_off_action:
      - ble_client.ble_write:
          id: scala
          service_uuid: ${geni_service}
          characteristic_uuid: ${geni_char}
          value: [0x27, 0x05, 0xE7, 0xF8, 0x03, 0x81, 0x05, 0xD5, 0xE4]

How the status is decoded

The package polls the status object the Grundfos app uses every 10 s. In the reply, two bytes matter:

Byte Meaning
S1 bit 0x04 disabled (STOP command)
S1 bit 0x40 demand – pressure low / water drawn
S2 bits 0x03 motor running (0B running, 0A running down, 08 stopped)

Seen in practice: 81 08 enabled + idle, C1 0B running, C5 08 → 45 08 disabled while demand persists.

Control uses plain GENIbus class-3 commands: START (6) to enable and STOP (5) to disable. The usual REMOTE (7) command is rejected by the SCALA1 as unknown.

Tested on one SCALA1 – feedback from other models/firmware welcome. Thanks to the ESPHome alpha3 component and the grundfos-bluetooth project’s protocol notes, which made this possible. Also to Claude!