Reverse Engineering eines proprietären UART-Protokolls am Beispiel eines Whirlpool-Steuergeräts (MSPA Muse Carlton)

| | Allgemein

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!

Neueste Beiträge

Der perfekte Einstieg in die Welt der Vinyls: Der Denon DP-300F und 2 Alternativen

Warum Vinyl?

Vinyl erlebt seit einigen Jahren ein riesiges Comeback. Der warme, analoge Klang, das bewusste Musikhören und das Sammeln von Schallplatten faszinieren immer mehr Musikliebhaber. Dabei ist nicht nur das nostalgische Feeling ausschlaggebend, sondern vor allem auch der unverwechselbare Klangcharakter von Vinyl – ein Klang, der trotz moderner digitaler Verfahren nach wie vor viele Fans begeistert.

Denon DP-300F[*] – Der ideale Allrounder für Einsteiger

Der Denon DP-300F[*] ist nach wie vor ein beliebter vollautomatischer Plattenspieler im Einsteigerbereich. Sein automatischer Tonarm sorgt für einen schonenden Umgang mit Nadel und Platte – perfekt, wenn du ohne großen Aufwand direkt in den Vinylgenuss starten möchtest.


Weiter >>

Ein Leitfaden für Senioren: Günstige Smartphones bis 100 Euro – Unsere 4 Favoriten & Erfahrungsbericht einer maßgeschneiderten Senioren-Lösung – UPDATE 2024/25

In diesem Beitrag stellen wir dir vier günstige Smartphones vor, die aktuell (Stand Dezember 2024) für unter 100 Euro erhältlich sind. Zusätzlich teilen wir eine ganz besondere Geschichte aus dem Familienkreis: Wie wir eines dieser Geräte für die Oma meiner Verlobten eingerichtet haben, damit sie trotz ihrer Parkinson-Erkrankung gut damit zurechtkommt.


Weiter >>

Getting Started with Alfresco SDK/Development: A Beginner’s Guide to Automating File Organization with Alfresco Behaviors

Alfresco is an enterprise content management platform known for its flexibility and extensibility. One powerful way to extend its functionality is through Behaviors, which allow you to run custom logic whenever specific repository events occur. For example, you can trigger custom actions whenever nodes are created, updated, or deleted.


Weiter >>