Einleitung
In diesem Artikel zeige ich dir, wie ich das serielle Kommunikationsprotokoll eines MSPA Muse Carlton Whirlpools erfolgreich reverse-engineered habe. Ziel war es, die Fernbedienungsbefehle auszulesen und eigene Kommandos zu senden. Dafür kam ein ESP32 Dev Board[*] zum Einsatz. Dieses Protokoll funktioniert vermutlich auch bei anderen MSPA-Modellen.
Dieser Artikel richtet sich an alle Maker, Home-Automatisierer und Technik-Fans, die gern tief in die Technik eintauchen.
Was ist UART?
UART (Universal Asynchronous Receiver/Transmitter) ist ein einfaches serielles Kommunikationsprotokoll, das in vielen Embedded-Systemen verwendet wird. Es arbeitet über zwei Leitungen:
- TX (Transmit): Daten senden
- RX (Receive): Daten empfangen
Die Kommunikation erfolgt asynchron, d. h. ohne externes Taktsignal. Stattdessen wird die Geschwindigkeit über eine feste Baudrate (z. B. 9600, 19200, 115200) definiert. UART ist leicht zu implementieren und daher ideal für DIY-Projekte.
Projektziel
Das Ziel war es, die Signale zwischen der Whirlpool-Hauptsteuerung und der Fernbedienung mitzulesen, zu analysieren und eigene Steuerkommandos zu senden. Dadurch kann man z. B. per Smart-Home-System Funktionen wie „JET_EIN“ oder „FILTER_AUS“ automatisieren.
Hardware-Setup: ESP32 als Sniffer
Verwendete Komponenten:
Keine weiteren Komponenten notwendig – der ESP32[*] kann UART-Signale direkt verarbeiten, da der MSPA-Controller ebenfalls mit 3.3V UART arbeitet.
Anschlussplan:
- Whirlpool RX → ESP32 GPIO17 (lesen) (Weiß, besser einmal ausprobieren)
- Whirlpool TX → ESP32 GPIO16 (senden) (Rot, besser einmal ausprobieren)
- GND → GND (Gelbes Kabel bei mir, besser einmal messen)
Wichtig: Immer zuerst mit einem Multimeter oder Oszilloskop prüfen, ob die Signale im 3.3V-Bereich liegen!
Weg zur Protokollanalyse
Automatisierter Button-Decoder: LogFernbedienungButtonClicks.ino
#define RX_PIN 16
#define BAUDRATE 9600
struct ButtonAction {
const char* label;
bool hasOnOff;
};
ButtonAction buttons[] = {
{"UP", false},
{"DOWN", false},
{"JET", true},
{"HEATER", true},
{"FILTER", true},
{"BOBBLE", true},
{"UVC", true},
{"OZONE", true},
{"TIMER", true},
};
const int numButtons = sizeof(buttons) / sizeof(buttons[0]);
int step = -2; // -2 = Grundrauschen, -1 = Pause
int substep = 0; // 0 = ON, 1 = OFF
unsigned long lastLogTime = 0;
bool waiting = false;
void setup() {
Serial.begin(115200);
Serial2.begin(BAUDRATE, SERIAL_8N1, RX_PIN, -1); // RX only
Serial.println("Whirlpool Button Sniffer");
Serial.println("Starte 20 Sekunden Grundrauschen...");
lastLogTime = millis();
}
void loop() {
// Live Data Logging
if (Serial2.available()) {
byte b = Serial2.read();
Serial.print("0x");
if (b < 0x10) Serial.print("0");
Serial.print(b, HEX);
Serial.print(" ");
}
unsigned long now = millis();
// Schritt 1: Grundrauschen
if (step == -2 && now - lastLogTime > 20000) {
Serial.println("\nGrundrauschen abgeschlossen.\n");
step = 0;
substep = 0;
waiting = false;
lastLogTime = now;
}
// Schritt 2: Button-Aktionen
if (step >= 0 && step < numButtons) {
ButtonAction current = buttons[step];
if (!waiting) {
Serial.println();
Serial.print("Bitte Taste drücken: ");
Serial.print(current.label);
if (current.hasOnOff) {
if (substep == 0) Serial.println(" EIN");
else Serial.println(" AUS");
} else {
Serial.println();
}
waiting = true;
lastLogTime = now;
}
// 10 Sekunden pro Aktion
if (waiting && now - lastLogTime > 10000) {
waiting = false;
if (current.hasOnOff) {
if (substep == 0) {
substep = 1; // Wechsel auf AUS
} else {
substep = 0;
step++; // Nächster Button
}
} else {
step++; // Nur ein Schritt nötig
}
}
}
// Schritt 3: fertig
if (step >= numButtons && !waiting) {
Serial.println("\nAlle Tasten durch. Logging abgeschlossen.");
step = 999; // blockiere loop
while(1) sleep(1);
}
}
Gleichzeitiges Logging: LoggerPoolUndFernbedienung.ino
#define RX_POOL 16 // Empfängt vom Pool (Data OUT vom Pool)
#define RX_REMOTE 17 // Empfängt von der Fernbedienung (Data OUT von deinem Sender, falls angeschlossen)
#define BAUDRATE 9600
String userNote = "";
void setup() {
Serial.begin(115200);
Serial1.begin(BAUDRATE, SERIAL_8N1, RX_REMOTE, -1); // Richtung: Fernbedienung -> Pool
Serial2.begin(BAUDRATE, SERIAL_8N1, RX_POOL, -1); // Richtung: Pool →->Fernbedienung
Serial.println("Bidirektionaler Whirlpool-Sniffer mit Kommentaren");
Serial.println("Eingabe z.B. 'SET TEMP 38' → wird geloggt");
Serial.println("----------------------------------------");
}
void loop() {
static byte buffer1[4], buffer2[4];
static int idx1 = 0, idx2 = 0;
static unsigned long lastByteTime1 = 0, lastByteTime2 = 0;
unsigned long now = millis();
// Eingabe vom Benutzer
if (Serial.available()) {
userNote = Serial.readStringUntil('\n');
userNote.trim();
if (userNote.length() > 0) {
Serial.println();
Serial.print("[");
Serial.print(now);
Serial.print("ms] Kommentar: ");
Serial.println(userNote);
Serial.println("----------------------------------------");
}
}
// Fernbedienung -> Pool (Serial1)
while (Serial1.available()) {
byte b = Serial1.read();
if (now - lastByteTime1 > 10) idx1 = 0;
buffer1[idx1++] = b;
lastByteTime1 = now;
if (idx1 == 4) {
Serial.print("[");
Serial.print(now);
Serial.print("ms] [FERNBEDIENUNG] ");
printFrame(buffer1);
idx1 = 0;
}
}
// Pool -> Fernbedienung (Serial2)
while (Serial2.available()) {
byte b = Serial2.read();
if (now - lastByteTime2 > 10) idx2 = 0;
buffer2[idx2++] = b;
lastByteTime2 = now;
if (idx2 == 4) {
Serial.print("[");
Serial.print(now);
Serial.print("ms] [POOL] ");
printFrame(buffer2);
idx2 = 0;
}
}
}
void printFrame(byte* frame) {
for (int i = 0; i < 4; i++) {
if (frame[i] < 0x10) Serial.print("0");
Serial.print(frame[i], HEX);
Serial.print(" ");
}
Serial.println();
}
Protokollstruktur verstehen
Die Struktur lautet:
[START][CODE][VALUE][CHECKSUM]
START = 0xA5
CODE = Funktion (Heizung, Jet, etc.)
VALUE = Zustand (0x01 = EIN, 0x00 = AUS)
CHECKSUM = (START + CODE + VALUE) & 0xFF
Eigene Befehle senden – Arduino C++ Beispiele (vollständig)
Beispielprogramm: ControllerMitTempUndPoolLogger.ino
// Whirlpool Steuerung mit RX/Status-Logging (ESP32 mit HardwareSerial)
#define POOL_TX 17 // TX zum Whirlpool
#define POOL_RX 16 // RX vom Whirlpool
HardwareSerial PoolSerial(1); // UART1 für RX/TX
bool heaterOn = false;
bool bubbleOn = false;
bool jetOn = false;
bool uvcOn = false;
bool ozoneOn = false;
bool filterOn = false;
bool pendingTemp = false;
byte targetTemp = 0;
byte currentTemp = 0;
byte remoteHeaterStatus = 0;
void setup() {
Serial.begin(115200);
PoolSerial.begin(9600, SERIAL_8N1, POOL_RX, POOL_TX);
delay(1000);
Serial.println("Whirlpool Steuerung & Statusüberwachung bereit");
Serial.println("Eingabe z.B. FILTER_EIN, TEMP_37");
}
void loop() {
if (Serial.available()) {
String input = Serial.readStringUntil('\n');
input.trim();
input.toUpperCase();
sendCommand(input);
}
handleIncomingFrames();
sendFilterSequence();
delay(1000);
}
void handleIncomingFrames() {
static byte buffer[4];
static byte index = 0;
while (PoolSerial.available()) {
byte b = PoolSerial.read();
if (b == 0xA5) {
index = 0;
buffer[index++] = b;
} else if (index > 0 && index < 4) {
buffer[index++] = b;
if (index == 4) {
byte checksum = (buffer[0] + buffer[1] + buffer[2]) & 0xFF;
byte code = buffer[1];
byte value = buffer[2];
Serial.print("[RX] Code: 0x"); Serial.print(code, HEX);
Serial.print(" | Value: 0x"); Serial.print(value, HEX);
Serial.print(" | Checksum OK: "); Serial.println(checksum == buffer[3] ? "JA" : "NEIN");
if (checksum != buffer[3]) return;
if (code == 0x06) {
currentTemp = value;
Serial.print("Aktuelle Temperatur: "); Serial.print(currentTemp/2); Serial.println(" °C");
}
}
}
}
}
void sendCommand(String cmd) {
Serial.print("Sende Befehl: "); Serial.println(cmd);
if (cmd == "FILTER_EIN") filterOn = true;
else if (cmd == "FILTER_AUS") filterOn = false;
else if (cmd == "HEATER_EIN") { heaterOn = true; filterOn = true; }
else if (cmd == "HEATER_AUS") heaterOn = false;
else if (cmd == "BOBBLE_EIN") { bubbleOn = true; ozoneOn = false; filterOn = true; }
else if (cmd == "BOBBLE_AUS") bubbleOn = false;
else if (cmd == "JET_EIN") { jetOn = true; ozoneOn = false; filterOn = false; }
else if (cmd == "JET_AUS") jetOn = false;
else if (cmd == "UVC_EIN") { uvcOn = true; filterOn = true; }
else if (cmd == "UVC_AUS") uvcOn = false;
else if (cmd == "OZONE_EIN") { ozoneOn = true; filterOn = true; }
else if (cmd == "OZONE_AUS") ozoneOn = false;
else if (cmd.startsWith("TEMP_")) {
int temp = cmd.substring(5).toInt();
if (temp >= 10 && temp <= 40) {
targetTemp = (byte)temp;
pendingTemp = true;
Serial.print("Zieltemperatur gesetzt: "); Serial.println(targetTemp);
} else {
Serial.println("Temperatur ungültig (10–40 °C)");
}
} else {
Serial.println("Unbekannter Befehl");
}
}
void sendFilterSequence() {
Serial.println("Sende Status-Sequenz");
if (filterOn) sendFrame(0x0B, 0x00);
else sendFrame(0x02, 0x00);
sendFrame(0x01, heaterOn ? 0x01 : 0x00);
sendFrame(0x03, bubbleOn ? 0x02 : 0x00);
sendFrame(0x0D, jetOn ? 0x01 : 0x00);
sendFrame(0x0E, ozoneOn ? 0x01 : 0x00);
sendFrame(0x15, uvcOn ? 0x01 : 0x00);
sendFrame(0x02, filterOn ? 0x01 : 0x00);
sendFrame(0x16, 0x00);
if (pendingTemp) {
sendFrame(0x04, targetTemp); // Temperatur setzen
pendingTemp = false;
// Wiederholungsblock – wichtig für Akzeptanz durch Pool
Serial.println("Sende Wiederholungsframes nach Temperatur-Setzung...");
// Filterstatus wiederholen
sendFrame(0x02, filterOn ? 0x01 : 0x00);
// Bubblestatus wiederholen
sendFrame(0x03, bubbleOn ? 0x02 : 0x00);
// Jetstatus immer auf OFF wiederholen
sendFrame(0x0D, 0x00);
}
}
void sendFrame(byte code, byte value) {
byte frame[4] = { 0xA5, code, value, (0xA5 + code + value) & 0xFF };
Serial.print("[TX] Frame: ");
for (int i = 0; i < 4; i++) {
if (frame[i] < 0x10) Serial.print("0");
Serial.print(frame[i], HEX); Serial.print(" ");
}
Serial.println();
PoolSerial.write(frame, 4);
delay(50);
}
Integration in Home Assistant mit ESPHome
In ESPHome wurden alle bekannten Befehle umgesetzt. Die Steuerung erfolgt über Template Switches und Lambda-Funktionen. Temperaturwerte werden über Code 0x06
gelesen, Befehle mit write_array()
gesendet.
Beispiel: UART-Logging in ESPHome
esphome:
name: whirlpool
friendly_name: whirlpool
esp32:
board: esp32dev
framework:
type: arduino
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: "xxx"
ota:
- platform: esphome
password: "xxx"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Whirlpool Fallback Hotspot"
password: "xxx"
captive_portal:
uart:
id: uart_bus
tx_pin: 17
rx_pin: 16
baud_rate: 9600
globals:
- id: pending_temp
type: bool
restore_value: no
initial_value: 'false'
- id: target_temp_val
type: int
restore_value: no
initial_value: '0'
sensor:
- platform: template
name: "Aktuelle Temperatur"
id: current_temp
unit_of_measurement: "°C"
accuracy_decimals: 1
update_interval: never
#text_sensor:
# - platform: template
# name: "Letztes UART Paket"
# id: last_uart
# update_interval: never
number:
- platform: template
name: "Zieltemperatur"
id: target_temp
min_value: 20
max_value: 40
step: 1
optimistic: true
on_value:
then:
- lambda: |-
id(target_temp_val) = (int)x;
id(pending_temp) = true;
switch:
- platform: template
name: "Heater"
id: heater_switch
optimistic: true
- platform: template
name: "Bubble"
id: bubble_switch
optimistic: true
- platform: template
name: "Jet"
id: jet_switch
optimistic: true
- platform: template
name: "UVC"
id: uvc_switch
optimistic: true
- platform: template
name: "Ozone"
id: ozone_switch
optimistic: true
- platform: template
name: "Filter"
id: filter_switch
optimistic: true
- platform: restart
name: "Whirlpool Controller Restart"
interval:
- interval: 200ms
then:
- lambda: |-
uint8_t b;
while (id(uart_bus).read_byte(&b)) {
static uint8_t buffer[4];
static int index = 0;
if (b == 0xA5) {
// Start eines neuen Frames, Puffer zurücksetzen
index = 0;
buffer[index++] = b;
ESP_LOGD("uart", "[RX] Start-Byte 0xA5 empfangen – neue Sequenz beginnt");
} else if (index > 0 && index < 4) {
buffer[index++] = b;
if (index == 4) {
// Sobald 4 Bytes im Puffer sind, komplette Sequenz loggen
ESP_LOGI("uart", "[RX] Komplette Sequenz empfangen: 0x%02X 0x%02X 0x%02X 0x%02X",
buffer[0], buffer[1], buffer[2], buffer[3]);
uint8_t checksum = (buffer[0] + buffer[1] + buffer[2]) & 0xFF;
if (checksum == buffer[3]) {
uint8_t code = buffer[1];
uint8_t value = buffer[2];
if (code == 0x06) {
id(current_temp).publish_state(value / 2.0f);
}
} else {
ESP_LOGW("uart", "[RX] Checksum-Fehler: Erwartet 0x%02X, erhalten 0x%02X", checksum, buffer[3]);
}
index = 0;
}
} else {
// Falls ein Byte außerhalb eines erwarteten Frames empfangen wird
ESP_LOGW("uart", "[RX] Unerwartetes Byte empfangen: 0x%02X", b);
}
}
- interval: 1s
then:
- lambda: |-
auto send_frame = [](uint8_t code, uint8_t value) {
uint8_t chk = (0xA5 + code + value) & 0xFF;
std::vector<uint8_t> frame = {0xA5, code, value, chk};
id(uart_bus).write_array(frame.data(), frame.size());
ESP_LOGI("uart send", "[TX] Code: 0x%02X, Value: 0x%02X", code, value);
//delay(50);
};
// Steuerlogik
send_frame(0x0B, id(filter_switch).state ? 0x00 : 0x02);
send_frame(0x01, id(heater_switch).state ? 0x01 : 0x00);
send_frame(0x03, id(bubble_switch).state ? 0x02 : 0x00);
send_frame(0x0D, id(jet_switch).state ? 0x01 : 0x00);
send_frame(0x0E, id(ozone_switch).state ? 0x01 : 0x00);
send_frame(0x15, id(uvc_switch).state ? 0x01 : 0x00);
send_frame(0x02, id(filter_switch).state ? 0x01 : 0x00);
send_frame(0x16, 0x00); // Heartbeat
if (id(pending_temp)) {
send_frame(0x04, id(target_temp_val) * 2);
id(pending_temp) = false;
// Wiederholungsframes
send_frame(0x02, id(filter_switch).state ? 0x01 : 0x00);
send_frame(0x03, id(bubble_switch).state ? 0x02 : 0x00);
send_frame(0x0D, 0x00); // Jet wieder auf OFF
}
Fazit & Ausblick
Dieses Reverse-Engineering-Projekt zeigt, wie einfach sich ein proprietäres UART-Protokoll mit einem ESP32[*] analysieren lässt – ganz ohne Spezialhardware. Durch systematisches Logging, Analyse und Testen konnte das MSPA-Protokoll entschlüsselt werden.
Die Einbindung in Home Assistant bietet spannende Möglichkeiten: Automatisierungen, Sprachsteuerung und Fernüberwachung des Pools.
Die Temperatursteuerung ist noch nicht vollständig gelöst – möglicherweise gibt es weitere Initialisierungsbefehle oder Einschränkungen. Falls du auch einen MSPA-Pool hast, probiere es gern selbst aus und teile deine Erkenntnisse!