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!