Your MSPA Goes Smart – Step-by-Step to a DIY Smart Home Hot Tub (Wi-Fi Upgrade)

| | Allgemein

Introduction

In this article, I’ll show you how I successfully reverse-engineered the serial communication protocol of an MSPA Muse Carlton hot tub. The goal was to read remote control commands and send custom ones. I used an ESP32 Dev Board[*] for this. This protocol likely works with other MSPA models as well.

This article is for makers, home automation enthusiasts, and tech fans who enjoy diving deep into technical systems.

What is UART?

UART (Universal Asynchronous Receiver/Transmitter) is a simple serial communication protocol used in many embedded systems. It operates over two lines:

  • TX (Transmit): Send data
  • RX (Receive): Receive data

The communication is asynchronous, meaning it doesn’t require an external clock signal. Instead, speed is defined by a fixed baud rate (e.g. 9600, 19200, 115200). UART is easy to implement, making it ideal for DIY projects.

Project Goal

The goal was to monitor and analyze the signals between the hot tub’s main controller and the remote, and send custom control commands. This allows you to automate functions like „JET_ON“ or „FILTER_OFF“ via a smart home system.

Hardware Setup: ESP32 as a Sniffer

Components Used:

No additional components are necessary – the ESP32[*] can process UART signals directly, since the MSPA controller also uses 3.3V UART.

Wiring Diagram:

  • Hot tub RX → ESP32 GPIO17 (read) (White – test to confirm)
  • Hot tub TX → ESP32 GPIO16 (send) (Red – test to confirm)
  • GND → GND (Yellow wire in my case – measure to confirm)

Important: Always use a multimeter or oscilloscope first to verify the signals are within the 3.3V range!

Path to Protocol Analysis

Automated Button Decoder: LogRemoteButtonClicks.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);
  }
}

Simultaneous Logging: LoggerPoolAndRemote.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();
}

Understanding Protocol Structure

The structure is as follows:

[START][CODE][VALUE][CHECKSUM]
START = 0xA5
CODE = Function (heater, jet, etc.)
VALUE = State (0x01 = ON, 0x00 = OFF)
CHECKSUM = (START + CODE + VALUE) & 0xFF

Sending Custom Commands – Arduino C++ Examples (Full)

Example Program: ControllerWithTempAndPoolLogger.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();
}

Integration into Home Assistant with ESPHome

All known commands were implemented in ESPHome. Control is done using template switches and lambda functions. Temperature values are read using code 0x06, and commands are sent using write_array().

Example: 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
          }

Conclusion & Outlook

This reverse-engineering project demonstrates how easy it is to analyze a proprietary UART protocol using an ESP32[*] – with no need for special hardware. Through systematic logging, analysis, and testing, the MSPA protocol was decoded.

Integration with Home Assistant offers exciting possibilities: automations, voice control, and remote pool monitoring.

Temperature control is not yet fully solved – there may be additional initialization commands or limitations. If you have an MSPA pool, feel free to try it yourself and share your findings!

Neueste Beiträge

Dein MSPA wird smart – Schritt-für-Schritt zum DIY Smart Home Whirlpool (WIFI/WLAN Upgrade) / Reverse Engineering eines proprietären UART-Protokolls am Beispiel eines Whirlpool-Steuergeräts (MSPA Muse Carlton)

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.


Weiter >>

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 >>