ESPHome Integration
Reference documentation for integrating Grundfos ALPHA HWR pumps with ESPHome and Home Assistant.
Overview
The ESPHome component acts as a BLE-to-WiFi bridge, exposing pump telemetry sensors to Home Assistant via the native ESPHome API.
Features
- Stable BLE connection with proper security configuration
- Real-time telemetry: flow, pressure, power, RPM, and temperature
- Active polling every 10 seconds
- Automatic reconnection handling
- Multi-packet reassembly for BLE fragmentation
- Native Home Assistant integration
Requirements
- ESP32 microcontroller with Bluetooth LE support
- ESPHome with ESP-IDF framework (required for BLE security)
- Pump within BLE range (~10m)
Component Implementation
The component consists of three files that work together to implement the GENI protocol over BLE.
File Structure
custom_components/
└── alpha_hwr/
├── __init__.py # Component registration
├── alpha_hwr.h # C++ header with protocol constants
└── alpha_hwr.cpp # Main implementation
1. Component Registration (__init__.py)
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor, ble_client
from esphome.const import (
CONF_ID,
UNIT_CELSIUS,
UNIT_WATT,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_POWER,
STATE_CLASS_MEASUREMENT,
)
DEPENDENCIES = ["ble_client"]
CODEOWNERS = ["@your-username"]
alpha_hwr_ns = cg.esphome_ns.namespace("alpha_hwr")
AlphaHwrComponent = alpha_hwr_ns.class_(
"AlphaHwrComponent", cg.PollingComponent, ble_client.BLEClientNode
)
CONF_FLOW = "flow"
CONF_HEAD = "head"
CONF_POWER = "power"
CONF_RPM = "rpm"
CONF_TEMP_MEDIA = "temp_media"
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(AlphaHwrComponent),
cv.Optional(CONF_FLOW): sensor.sensor_schema(
unit_of_measurement="m³/h",
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HEAD): sensor.sensor_schema(
unit_of_measurement="m",
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RPM): sensor.sensor_schema(
unit_of_measurement="RPM",
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMP_MEDIA): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
}).extend(ble_client.BLE_CLIENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
if CONF_FLOW in config:
sens = await sensor.new_sensor(config[CONF_FLOW])
cg.add(var.set_flow_sensor(sens))
if CONF_HEAD in config:
sens = await sensor.new_sensor(config[CONF_HEAD])
cg.add(var.set_head_sensor(sens))
if CONF_POWER in config:
sens = await sensor.new_sensor(config[CONF_POWER])
cg.add(var.set_power_sensor(sens))
if CONF_RPM in config:
sens = await sensor.new_sensor(config[CONF_RPM])
cg.add(var.set_rpm_sensor(sens))
if CONF_TEMP_MEDIA in config:
sens = await sensor.new_sensor(config[CONF_TEMP_MEDIA])
cg.add(var.set_temp_media_sensor(sens))
2. Protocol Constants (alpha_hwr.h)
Key sections of the header file:
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/sensor/sensor.h"
#include <esp_gattc_api.h>
#include <esp_gap_ble_api.h>
#include <esp_bt_defs.h>
namespace esphome {
namespace alpha_hwr {
static const char *TAG = "alpha_hwr";
// GENI Service UUIDs
static const uint16_t GRUNDFOS_SERVICE_UUID = 0xFE5D;
static ESPBTUUID GENI_CHAR_UUID =
ESPBTUUID::from_raw("859cffd1-036e-432a-aa28-1a0085b87ba9");
// Authentication packets
static const uint8_t AUTH_LEGACY[] = {
0x27, 0x07, 0xE7, 0xF8, 0x02, 0x03, 0x94, 0x95, 0x96, 0xEB, 0x47
};
static const uint8_t AUTH_CLASS10[] = {
0x27, 0x07, 0xE7, 0xF8, 0x0A, 0x03, 0x56, 0x00, 0x06, 0xC5, 0x5A
};
static const uint8_t AUTH_EXT_1[] = {
0x27, 0x05, 0xE7, 0xF8, 0x05, 0xC1, 0x4B, 0xC3, 0x82
};
static const uint8_t AUTH_EXT_2[] = {
0x27, 0x05, 0xE7, 0xF8, 0x0B, 0xC1, 0x0F, 0xD0, 0xC3
};
// CRC-16-CCITT lookup table for GENI protocol
// Full 256-entry table available in protocol documentation
static const uint16_t CRC16_TABLE[256] = {
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
0x2463, 0x3442, 0x0421, 0x1400, 0x64E7, 0x74C6, 0x44A5, 0x5484,
0xA56B, 0xB54A, 0x8529, 0x9508, 0xE5EF, 0xF5CE, 0xC5AD, 0xD58C,
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
0x02B1, 0x1290, 0x22D3, 0x32F2, 0x4235, 0x5214, 0x6277, 0x7256,
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DC8, 0x8DE9,
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0
};
class AlphaHwrComponent : public PollingComponent, public ble_client::BLEClientNode {
public:
explicit AlphaHwrComponent(ble_client::BLEClient *parent) : PollingComponent(10000) {
parent->register_ble_node(this);
parent_ = parent;
}
void set_flow_sensor(sensor::Sensor *sensor) { flow_sensor_ = sensor; }
void set_head_sensor(sensor::Sensor *sensor) { head_sensor_ = sensor; }
void set_power_sensor(sensor::Sensor *sensor) { power_sensor_ = sensor; }
void set_rpm_sensor(sensor::Sensor *sensor) { rpm_sensor_ = sensor; }
void set_temp_media_sensor(sensor::Sensor *sensor) { temp_media_sensor_ = sensor; }
void setup() override;
void loop() override;
void update() override;
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
private:
ble_client::BLEClient *parent_;
sensor::Sensor *flow_sensor_{nullptr};
sensor::Sensor *head_sensor_{nullptr};
sensor::Sensor *power_sensor_{nullptr};
sensor::Sensor *rpm_sensor_{nullptr};
sensor::Sensor *temp_media_sensor_{nullptr};
// Protocol implementation
float read_float_be(uint8_t *data, size_t offset);
void decode_packet(uint8_t *data, size_t len);
void authenticate();
void send_auth_packet(const uint8_t *data, size_t len);
void subscribe_to_notifications();
void poll_telemetry();
void send_read_request(uint32_t register_addr);
uint16_t calc_crc16(const uint8_t *data, size_t len);
// State tracking
bool authenticated_ = false;
bool subscribed_ = false;
// Packet reassembly for multi-packet BLE responses
std::vector<uint8_t> reassembly_buffer_;
uint8_t expected_packet_length_ = 0;
bool reassembling_ = false;
};
} // namespace alpha_hwr
} // namespace esphome
3. Implementation Highlights (alpha_hwr.cpp)
BLE Security Configuration
Critical for preventing disconnections:
void AlphaHwrComponent::setup() {
// Configure BLE security - prevents disconnect issue (0x13 error)
esp_ble_io_cap_t iocap = ESP_IO_CAP_NONE;
esp_ble_gap_set_security_param(ESP_BLE_SM_IOCAP_MODE, &iocap, sizeof(uint8_t));
uint8_t auth_req = ESP_LE_AUTH_REQ_SC_BOND;
esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE, &auth_req, sizeof(uint8_t));
uint8_t key_size = 16;
esp_ble_gap_set_security_param(ESP_BLE_SM_MAX_KEY_SIZE, &key_size, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_MIN_KEY_SIZE, &key_size, sizeof(uint8_t));
ESP_LOGI(TAG, "BLE security configured (bonding enabled)");
}
Three-Stage Authentication
void AlphaHwrComponent::authenticate() {
// Stage 1: Legacy magic burst (3x)
for (int i = 0; i < 3; i++) {
send_auth_packet(AUTH_LEGACY, sizeof(AUTH_LEGACY));
delay(50);
}
// Stage 2: Class 10 unlock burst (5x)
for (int i = 0; i < 5; i++) {
send_auth_packet(AUTH_CLASS10, sizeof(AUTH_CLASS10));
delay(50);
}
// Stage 3: Extensions (2x)
send_auth_packet(AUTH_EXT_1, sizeof(AUTH_EXT_1));
delay(50);
send_auth_packet(AUTH_EXT_2, sizeof(AUTH_EXT_2));
delay(500);
authenticated_ = true;
ESP_LOGI(TAG, "Authentication completed");
}
Active Telemetry Polling
Pump requires polling, not passive listening:
void AlphaHwrComponent::poll_telemetry() {
ESP_LOGI(TAG, "Polling telemetry...");
// Request motor state (0x570045)
send_read_request(0x570045);
// Request flow/pressure (0x5D0122)
this->set_timeout(100, [this]() {
send_read_request(0x5D0122);
});
// Request temperature (0x5D012C)
this->set_timeout(200, [this]() {
send_read_request(0x5D012C);
});
}
void AlphaHwrComponent::update() {
// Called every 10 seconds by PollingComponent
if (authenticated_ && parent_ && parent_->get_conn_id() != 0xFF) {
poll_telemetry();
}
}
Building READ Requests
void AlphaHwrComponent::send_read_request(uint32_t register_addr) {
uint8_t packet[11];
// Frame structure: [27][07][E7][F8][0A][03][Reg-H][Reg-M][Reg-L][CRC-H][CRC-L]
packet[0] = 0x27; // Frame start (request)
packet[1] = 0x07; // Length
packet[2] = 0xE7; // Service ID high
packet[3] = 0xF8; // Source
packet[4] = 0x0A; // Class 10
packet[5] = 0x03; // OpSpec (READ)
packet[6] = (register_addr >> 16) & 0xFF; // Register high byte
packet[7] = (register_addr >> 8) & 0xFF; // Register mid byte
packet[8] = register_addr & 0xFF; // Register low byte
// Calculate CRC-16-CCITT over bytes 1-8, XOR result with 0xFFFF
uint16_t crc = calc_crc16(packet + 1, 8);
packet[9] = (crc >> 8) & 0xFF; // CRC high byte
packet[10] = crc & 0xFF; // CRC low byte
// Send via BLE characteristic
auto *chr = parent_->get_characteristic(GRUNDFOS_SERVICE_UUID, GENI_CHAR_UUID);
if (chr) {
auto status = esp_ble_gattc_write_char(
parent_->get_gattc_if(),
parent_->get_conn_id(),
chr->handle,
sizeof(packet),
packet,
ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE);
}
}
Packet Reassembly
Handles BLE 20-byte MTU limitation:
case ESP_GATTC_NOTIFY_EVT: {
auto *notify_evt = ¶m->notify;
if (notify_evt->value_len > 0) {
// Check if this is start of new packet (frame byte 0x24 or 0x27)
if (notify_evt->value[0] == 0x24 || notify_evt->value[0] == 0x27) {
if (notify_evt->value_len >= 2) {
expected_packet_length_ = notify_evt->value[1] + 2;
}
reassembly_buffer_.clear();
reassembly_buffer_.insert(reassembly_buffer_.end(),
notify_evt->value,
notify_evt->value + notify_evt->value_len);
reassembling_ = true;
} else if (reassembling_) {
// Continuation packet
reassembly_buffer_.insert(reassembly_buffer_.end(),
notify_evt->value,
notify_evt->value + notify_evt->value_len);
}
// Check if packet is complete
if (reassembling_ && reassembly_buffer_.size() >= expected_packet_length_) {
decode_packet(reassembly_buffer_.data(), reassembly_buffer_.size());
reassembling_ = false;
reassembly_buffer_.clear();
}
}
break;
}
Response Decoding
void AlphaHwrComponent::decode_packet(uint8_t *data, size_t len) {
if (len < 10) return;
// Check frame type (0x24 = response) and class (0x0A = Class 10)
if (data[0] != 0x24 || data[4] != 0x0A) return;
uint8_t opspec = data[5];
// Motor state response (OpSpec 0x30)
if (opspec == 0x30 && len >= 37) {
// Floats start at offset 13
float power = read_float_be(data, 25); // Float[3] at offset 13+12
float rpm = read_float_be(data, 33); // Float[5] at offset 13+20
if (power >= 0 && power <= 1000 && rpm >= 0 && rpm <= 10000) {
if (power_sensor_) power_sensor_->publish_state(power);
if (rpm_sensor_) rpm_sensor_->publish_state(rpm);
}
}
// Flow/pressure response (OpSpec 0x2B)
else if (opspec == 0x2B && len >= 45) {
float flow = read_float_be(data, 37); // Float[6] at offset 13+24
float head = read_float_be(data, 41); // Float[7] at offset 13+28
if (flow >= 0 && flow <= 100 && head >= 0 && head <= 50) {
if (flow_sensor_) flow_sensor_->publish_state(flow);
if (head_sensor_) head_sensor_->publish_state(head);
}
}
// Temperature response (OpSpec 0x14)
else if (opspec == 0x14 && len >= 21) {
float temp = read_float_be(data, 13); // Single float at offset 13
if (temp >= -50 && temp <= 150) {
if (temp_media_sensor_) temp_media_sensor_->publish_state(temp);
}
}
}
float AlphaHwrComponent::read_float_be(uint8_t *data, size_t offset) {
// Read IEEE 754 big-endian float
uint32_t temp = (data[offset] << 24) | (data[offset+1] << 16) |
(data[offset+2] << 8) | data[offset+3];
float val;
memcpy(&val, &temp, 4);
return val;
}
Configuration
ESPHome YAML
esphome:
name: alpha-hwr-bridge
friendly_name: ALPHA HWR Pump
external_components:
- source:
type: local
path: custom_components
components: [alpha_hwr]
esp32:
board: esp32-c3-devkitm-1
variant: esp32c3
framework:
type: esp-idf # Required for BLE security
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
logger:
level: DEBUG
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
password: !secret ota_password
esp32_ble_tracker:
scan_parameters:
interval: 1.1s
window: 1.1s
ble_client:
- mac_address: "AA:BB:CC:DD:EE:FF" # Replace with your pump's actual MAC address
id: alpha_pump
alpha_hwr:
ble_client_id: alpha_pump
flow:
name: "Flow Rate"
head:
name: "Head Pressure"
power:
name: "Power"
rpm:
name: "Motor RPM"
temp_media:
name: "Water Temperature"
Protocol Reference
GENI Frame Structure
READ Request (11 bytes):
[27] [07] [E7] [F8] [0A] [03] [Reg-H] [Reg-M] [Reg-L] [CRC-H] [CRC-L]
^ ^ ^ ^ ^ ^ ^--------register-------^ ^----CRC----^
| | | | | |
| | | | | OpSpec (0x03 = READ)
| | | | Class 10
| | | Source (0xF8)
| | Service ID High (0xE7)
| Length (7 bytes)
Frame Start (0x27 for requests)
Response:
[24] [Len] [F8] [E7] [0A] [OpSpec] [Counters(6)] [Res(2)] [Floats...] [CRC(2)]
^ ^ ^ ^
| | | Data starts at offset 13
| | Response OpSpec
| Total length
Response Frame (0x24)
Register Map
| Register | Description | OpSpec | Data |
|---|---|---|---|
0x570045 |
Motor state | 0x30 | Power (W), RPM |
0x5D0122 |
Flow/pressure | 0x2B | Flow (m³/h), Head (m) |
0x5D012C |
Temperature | 0x14 | Media temp (°C) |
CRC Calculation
CRC-16-CCITT with initial value 0xFFFF: - For READ requests: XOR final result with 0xFFFF - Calculate over length through register bytes (not including frame start or CRC)
Troubleshooting
Connection Issues
Disconnect with error 0x13: Ensure BLE security is configured in setup().
No telemetry: Check authentication completed and polling is active every 10 seconds.
Compilation errors: Must use ESP-IDF framework, not Arduino.
Log Messages
Successful operation:
[I][alpha_hwr]: BLE security configured (bonding enabled)
[I][alpha_hwr]: Connected to pump
[I][alpha_hwr]: Authentication completed
[I][alpha_hwr]: Polling telemetry...
[I][alpha_hwr]: Motor: Power=0.0 W, RPM=0
[I][alpha_hwr]: Flow/Head: 0.000 m³/h, 0.00 m
[I][alpha_hwr]: Temp: 18.3°C
Advanced Topics
Multiple Pumps
ble_client:
- mac_address: "AA:BB:CC:DD:EE:01" # Replace with pump 1's actual MAC address
id: pump1
- mac_address: "AA:BB:CC:DD:EE:02" # Replace with pump 2's actual MAC address
id: pump2
alpha_hwr:
- ble_client_id: pump1
flow:
name: "Pump 1 Flow"
- ble_client_id: pump2
flow:
name: "Pump 2 Flow"
Custom Polling Interval
Modify alpha_hwr.h constructor:
explicit AlphaHwrComponent(ble_client::BLEClient *parent)
: PollingComponent(30000) { // 30s instead of 10s
Additional Registers
The pump exposes many more registers. Add to poll_telemetry():
Then add corresponding decode cases in decode_packet().
Limitations
- Read-only telemetry (no control commands)
- Single BLE connection to pump at a time
- Cannot read/modify pump configuration
- Cannot access operation schedules
For pump control, use the Python library or mobile app.