JoyOnWay Spa Control

Hello. I've made some progress.
I cloned GitHub - KnapTheBuilder/ha-joyonway-p23b32: Home Assistant custom integration for Joyonway P23B32 spa via RS485 · GitHub (master).
That's also in my HA and is displayed in version 0.3.2

I have created a test.py for debugging that directly uses rs485.py.

It couldn't find the broadcast status.

cd /home/timo/Projekte/esp32/pool/ha-joyonway-p23b32 ; /usr/bin/env /usr/bin/python3 /home/timo/.vscode/extensions/ms-python.debugpy-2026.6.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher 41363 -- /home/timo/Projekte/esp32/pool/ha-joyonway-p23b32/test.py --ip 192.168.178.70 --port 8080 --log-level DEBUG 
2026-06-14 13:15:29,814 INFO     joyonway_rs485_test: Testing TCP connection to 192.168.178.70:8080
2026-06-14 13:15:29,874 INFO     joyonway_rs485_test: Connection test: OK
2026-06-14 13:15:29,874 INFO     joyonway_rs485_test: Reading one broadcast status frame
2026-06-14 13:15:35,624 DEBUG    _joyonway_p23b32_standalone.rs485: W610 timeout: 1 octets lus, pas de trame complete

After analyzing my data, I found a very similar packet. I modified it in rs485.py.
from
BROADCAST_SIGNATURE = bytes([0x1A, 0xFF, 0x01, 0x3C, 0xD2, 0xB4, 0xFF, 0x08, 0x02])
to
BROADCAST_SIGNATURE = bytes([0x1A, 0xFF, 0x01, 0x3C, 0xD2, 0xB4, 0xFF, 0x08, 0x03])

The second attempt worked much better.

 decoded:
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   water_temperature:  22.8
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   setpoint:           30.6
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   pompe_gauche:       False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   pompe_droite:       False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   filtration:         False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   chauffage:          False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   chauffage_demande:  False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   ozone:              False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   bulleur:            False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   lumiere:            False
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   raw_pb1:            0
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   raw_pb2:            0
2026-06-14 13:15:54,466 INFO     joyonway_rs485_test:   raw_lb:             0```

Then I’ll try switching to my HA using alexbde/ha-joyonway.

@tprommi This is very interesting behavior! Until now, the assumption has been that byte 8 in the broadcast signature depends on the controller model:


(See section 4 in protocol reference)

If you have a P23B32 which broadcasts 0x03 we might need to revisit this.

Is the alexbde/ha-joyonway integration working for you? Which board version do you have if you go to your spa touchpad > Menu > Set > About > scroll one up (or all the way down)? Mine is:

Hi @tprommi, great find, and thanks for digging into this.

On my side the broadcast signature ends in 0x02 and has been stable in production for weeks on a P23B32. But per @alexbde's protocol reference (section 4), byte 8 is the Model ID: 0x02 for P23B32 / P20B29, 0x03 for P25B85.

So your controller broadcasting 0x03 is most likely identifying as a P25B85, not a P23B32. That explains why my 0x02 signature never matched, and why forcing 0x03 suddenly worked, you're reading a P25B85 broadcast.

Worth knowing: water temp and setpoint decoded fine because those byte positions line up, but the rest of the map differs between models (panel command prefix, pump and heater masks). Decoding a P25B85 frame with my P23B32 map can give wrong states on the other channels. For a P25B85 you'd want the matching adapter, and @alexbde's integration already has a modular adapter targeting it, likely the cleaner path for your hardware.

Could you confirm your exact model on the touchpad? Menu > Set > About, then scroll all the way up (or down). If it says P25B85, that settles it. If it really reads P23B32 while broadcasting 0x03, then we have a genuine hardware oddity worth digging into.

Sorry, that was a mistake on my part. I opened the pool again and found that I have a P25B37-2032. My apologies. I now have the HACS from alexbdb. Most things work right out of the box. Great job!

Panel Version 1.8
Board Version 1.8

There are a few issues, though.

  • sensor.joyonway_p25b85_dusen is correct. It detects levels 1 and 2 of the pump. fan.joyonway_p25b85_dusen is a toggle button; it switches between Off and (Level 1/2). But I can’t control anything with it. Im missing Jet Controle.

  • I can change the lighting color on the display. Off and On are displayed correctly in switch.joyonway_p25b85_light. Every time I turn the light on and off, it changes to the next color.

  • joyonway_p25b85_clock_auto_sync does nothing.

  • sensor.joyonway_p25b85_status is always unknown.

  • I don’t know if the dashboard generates automatically.
    switch.joyonway_p25b85_air_diffuser is not configured for my pool.

I'll take another look at the protocol. Maybe I can contribute something.

Timo

Hello,

:frowning: I have the same message, I can share with you my script that allows me to control my entire spa system, if that can help.

Hi @tprommi, hi @Yannickt26,

Thanks for the updates, this is extremely valuable for mapping the controller family models!

@tprommi (P25B37)

It is great news that the P25B37 broadcasts using the 0x03 signature byte and that all standard status readings (temperature, setpoint, light status, and pump levels) decode correctly using the P25B85 mapping out of the box.

The fact that you cannot control the light or pumps from Home Assistant suggests that although the P25B37 broadcasts like a P25B85, it expects different command framing.

  • Command Prefix offset: The P25B85 commands use the prefix 01 20 10 3C [Type] 10 A1 (variant byte 0x10 at index 5). It is highly likely the P25B37 expects 0x00 at index 5 (like the P23B32/P20B29: 01 20 10 3C [Type] 00 A1 or even 01 30 10 3C ...).
  • Command structure: It might also expect discrete ON/OFF commands instead of toggle cycles.

I adjusted the sniffer/guided capture script tools/guided_capture.py in the PR #56 branch so you can capture these commands directly.

Here is how you can use it:

  1. Pull the latest code from the branch and activate your virtual environment (see the Getting Started guide in CONTRIBUTING.md for details on setting up and activating the environment).
  2. Run the script:
    .venv/bin/python tools/guided_capture.py --host <YOUR_SPA_BRIDGE_IP> --port <PORT>
    
  3. Select Option 5) Capture P25B37 Touchpad Commands (Light & Pump).
  4. Go to your spa's physical topside touchpad and follow the on-screen steps. The script will guide you step-by-step:
    • Step 1: Ensure the Light is OFF (detects baseline).
    • Step 2: Press the Light button to turn the Light ON.
    • Step 3: Press the Light button again to turn the Light OFF.
    • Step 4: Ensure Jets are OFF.
    • Step 5: Press the Jets button to turn them to LOW speed.
    • Step 6: Press the Jets button to turn them to HIGH speed.
    • Step 7: Press the Jets button to turn them OFF.
  5. If the script fails to auto-detect a step transition, you can press Ctrl+C to skip/force-advance to the next step.
  6. Once completed, the script will output a clean summary of the captured command frames in your terminal. Please paste this console summary here, or upload the generated binary file tools/captures/p25b37_touchpad/p25b37_touchpad_transitions_<timestamp>.bin!

By the way, do you have a GitHub account? If you do, feel free to share the details/captures in an issue on the repository, or even submit a branch/fork directly!

@Yannickt26 (P20B29)

Regarding the ZLAN5143D — thanks for sharing! It's a great reference for a reliable Ethernet-to-RS485 bridge.

I noticed in your Post #109 capture that your P20B29 actually broadcasts 0x01 at index 8 (1a ff 01 3c d2 b4 ff 08 01...), whereas the P23B32 uses 0x02 and the P25B85/P25B37 use 0x03. This explains why the config flow's auto-detection failed on your setup (as it was only looking for 0x02 and 0x03).

You mentioned you have a working script that successfully controls your entire spa system. Please share your script here! Comparing your working command frames side-by-side with our P23/P25 adapters will help us add full support for the P20B29 model in the integration.

I’ve carried out a few more tests and described the situation again in more detail. The guided capture was also performed: Light and Jet control of P25B37 · Issue #57 · alexbde/ha-joyonway · GitHub

Hello, Here you find my script

import socket
import time
import json
import threading
from datetime import datetime
import paho.mqtt.client as mqtt

SPA_IP = "192.168.1.11"
SPA_PORT = 8899

MQTT_HOST = "192.168.1.XX"
MQTT_PORT = 1883
MQTT_USER = "Usernam"
MQTT_PASSWORD = "Password"

DEVICE_ID = "spa_joyonway"

DEVICE = {
"identifiers": [DEVICE_ID],
"name": "Spa Joyonway",
"manufacturer": "Joyonway",
"model": "SPA Controller",
"sw_version": "2.4"
}

LIGHT_MAP = {
0x00: "OFF",
0x01: "Multi",
0x02: "Rouge",
0x03: "Vert",
0x04: "Jaune",
0x05: "Bleu",
0x06: "Marron",
0x07: "Bleu Clair",
0x08: "Blanc",
}

COLOR_MAP = {
0x00: "#000000",
0x01: "#FFFFFF",
0x02: "#FF0000",
0x03: "#00FF00",
0x04: "#FFFF00",
0x05: "#0000FF",
0x06: "#8B4513",
0x07: "#87CEEB",
0x08: "#FFFFFF",
}

COMMANDS = {
"LIGHT_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 81 ed ba a0 1b 14 1d"),
"LIGHT_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 80 5a 20 cd c1 1d"),

"LEFT_PUMP_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 06 04 00 00 02 04 00 00 00 8b 3e e4 13 1d"),
"LEFT_PUMP_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 06 00 00 00 02 04 00 00 00 08 bd 10 33 1d"),

"RIGHT_PUMP_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 18 10 00 00 02 04 00 00 00 40 d1 2d e0 1d"),
"RIGHT_PUMP_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 18 00 00 00 02 04 00 00 00 4c df ff 63 1d"),

"BLOWER_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 04 04 02 04 00 00 00 0f 7f 1b 11 76 1d"),
"BLOWER_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 04 00 02 04 00 00 00 fc c2 86 4f 1d"),

"FILTRATION_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 80 80 00 00 02 04 00 00 00 79 54 80 d8 1d"),
"FILTRATION_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 80 00 00 00 02 04 00 00 00 c5 52 14 d5 1d"),

"TEMP_10": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 32 00 34 22 13 8e 1d"),
"TEMP_38": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 64 00 96 20 61 e1 1d"),

"COLOR_WHITE": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 87 31 de 4f dd 1d"),

}

SETPOINT_FRAMES_C = {
10: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 32 00 34 22 13 8e 1d"),
11: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 34 00 26 c9 c0 8a 1d"),
12: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 36 00 28 90 71 89 1d"),
13: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 37 00 af 3c a9 88 1d"),
14: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 39 00 85 b3 bf 82 1d"),
15: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3b 00 8b ea 0e 81 1d"),
16: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3d 00 99 01 dd 85 1d"),
17: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3f 00 97 58 6c 86 1d"),
18: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 40 00 6a 02 16 dd 1d"),
19: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 42 00 64 5b a7 de 1d"),
20: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 44 00 76 b0 74 da 1d"),
21: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 46 00 78 e9 c5 d9 1d"),
22: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 48 00 52 66 d3 d3 1d"),
23: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 49 00 d5 ca 0b d2 1d"),
24: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4b 00 db 93 ba d1 1d"),
25: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4d 00 c9 78 69 d5 1d"),
26: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4f 00 c7 21 d8 d6 1d"),
27: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 51 00 9d 66 44 c1 1d"),
28: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 52 00 14 93 2d c3 1d"),
29: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 54 00 06 78 fe c7 1d"),
30: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 56 00 08 21 4f c4 1d"),
31: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 58 00 22 ae 59 ce 1d"),
32: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5a 00 2c f7 e8 cd 1d"),
33: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5b 00 ab 5b 30 cc 1d"),
34: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5d 00 b9 b0 e3 c8 1d"),
35: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5f 00 b7 e9 52 cb 1d"),
36: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 61 00 0d 3e db e7 1d"),
37: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 63 00 03 67 6a e4 1d"),
38: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 64 00 96 20 61 e1 1d"),
39: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 66 00 98 79 d0 e2 1d"),
40: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 68 00 b2 f6 c6 e8 1d"),
}

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)

spa_socket = None
socket_lock = threading.Lock()
last_values = {}

def send(topic, value, force=False):
value = str(value)
if force or last_values.get(topic) != value:
last_values[topic] = value
client.publish(topic, value, retain=True)

def send_spa_command(name):
cmd = COMMANDS.get(name)
if not cmd:
return

def worker():
    for _ in range(10):
        try:
            with socket_lock:
                if spa_socket:
                    spa_socket.sendall(cmd)
        except Exception as e:
            print("Erreur commande :", e)

        time.sleep(0.5)

threading.Thread(target=worker, daemon=True).start()

def send_setpoint_thermostat(temp_c):
try:
temp_c = int(round(float(temp_c)))
except:
return

frame = SETPOINT_FRAMES_C.get(temp_c)

if frame is None:
    print("Consigne invalide :", temp_c)
    return

def worker():
    for _ in range(10):
        try:
            with socket_lock:
                if spa_socket:
                    spa_socket.sendall(frame)
        except Exception as e:
            print("Erreur thermostat :", e)

        time.sleep(0.5)

threading.Thread(target=worker, daemon=True).start()

# Retour MQTT immédiat pour Jeedom + Home Assistant
send("spa/setpoint", temp_c, force=True)
send("spa/thermostat", temp_c, force=True)

print(f"Thermostat -> {temp_c}°C")

def send_spa_command_verified(command_name, topic_check, expected_value):
send_spa_command(command_name)

def verifier():
    for attempt in range(2):
        time.sleep(9)

        current = last_values.get(topic_check)

        if str(current) == str(expected_value):
            print(f"[OK] {command_name} validée")
            return

        if attempt == 0:
            print(f"[RETRY] {command_name} non validée -> renvoi")
            send_spa_command(command_name)

    print(f"[ERREUR] {command_name} non validée")

threading.Thread(target=verifier, daemon=True).start()

def ha_discovery():

def sw(name, topic):
    client.publish(
        f"homeassistant/switch/{DEVICE_ID}_{topic}/config",
        json.dumps({
            "name": name,
            "unique_id": f"{DEVICE_ID}_{topic}",
            "state_topic": f"spa/{topic}",
            "command_topic": f"spa/cmd/{topic}",
            "payload_on": "1",
            "payload_off": "0",
            "device": DEVICE
        }),
        retain=True
    )

def sensor(name, topic):
    client.publish(
        f"homeassistant/sensor/{DEVICE_ID}_{topic}/config",
        json.dumps({
            "name": name,
            "unique_id": f"{DEVICE_ID}_{topic}",
            "state_topic": f"spa/{topic}",
            "device": DEVICE
        }),
        retain=True
    )

def binary_sensor(name, topic):
    client.publish(
        f"homeassistant/binary_sensor/{DEVICE_ID}_{topic}/config",
        json.dumps({
            "name": name,
            "unique_id": f"{DEVICE_ID}_{topic}",
            "state_topic": f"spa/{topic}",
            "payload_on": "1",
            "payload_off": "0",
            "device": DEVICE
        }),
        retain=True
    )

def button(name, topic):
    client.publish(
        f"homeassistant/button/{DEVICE_ID}_{topic}/config",
        json.dumps({
            "name": name,
            "unique_id": f"{DEVICE_ID}_{topic}",
            "command_topic": f"spa/cmd/{topic}",
            "device": DEVICE
        }),
        retain=True
    )

def number(name, topic):
    client.publish(
        f"homeassistant/number/{DEVICE_ID}_{topic}/config",
        json.dumps({
            "name": name,
            "unique_id": f"{DEVICE_ID}_{topic}",
            "state_topic": "spa/setpoint",
            "command_topic": f"spa/cmd/{topic}",
            "min": 10,
            "max": 40,
            "step": 1,
            "unit_of_measurement": "°C",
            "mode": "slider",
            "device": DEVICE
        }),
        retain=True
    )

sw("Spa Lumière", "etat_lumiere")
sw("Spa Pompe Gauche", "pompe_gauche")
sw("Spa Pompe Droite", "pompe_droite")
sw("Spa Blower", "blower")
sw("Spa Filtration", "filtration")

sensor("Spa Eau", "water_temp")
sensor("Spa Consigne", "setpoint")
sensor("Spa Thermostat MQTT", "thermostat")
sensor("Spa Couleur", "couleur")
sensor("Spa Couleur HEX", "couleur_hex")
sensor("Spa Heure", "heure")

binary_sensor("Spa Chauffe", "heater")
binary_sensor("Spa Ozonateur", "ozonateur")

button("Température 10°C", "temp_10")
button("Température 38°C", "temp_38")
button("Couleur Blanche", "color_white")

number("Spa Thermostat", "thermostat")

def on_connect(client, userdata, flags, reason_code, properties):
print("MQTT connecté")
client.subscribe("spa/cmd/#")
ha_discovery()

def on_message(client, userdata, msg):

topic = msg.topic
payload = msg.payload.decode().strip().upper()

if topic == "spa/cmd/etat_lumiere":
    if payload in ["1", "ON"]:
        send_spa_command_verified("LIGHT_ON", "spa/etat_lumiere", "1")
    else:
        send_spa_command_verified("LIGHT_OFF", "spa/etat_lumiere", "0")

elif topic == "spa/cmd/pompe_gauche":
    if payload in ["1", "ON"]:
        send_spa_command_verified("LEFT_PUMP_ON", "spa/pompe_gauche", "1")
    else:
        send_spa_command_verified("LEFT_PUMP_OFF", "spa/pompe_gauche", "0")

elif topic == "spa/cmd/pompe_droite":
    if payload in ["1", "ON"]:
        send_spa_command_verified("RIGHT_PUMP_ON", "spa/pompe_droite", "1")
    else:
        send_spa_command_verified("RIGHT_PUMP_OFF", "spa/pompe_droite", "0")

elif topic == "spa/cmd/blower":
    if payload in ["1", "ON"]:
        send_spa_command_verified("BLOWER_ON", "spa/blower", "1")
    else:
        send_spa_command_verified("BLOWER_OFF", "spa/blower", "0")

elif topic == "spa/cmd/filtration":
    if payload in ["1", "ON"]:
        send_spa_command_verified("FILTRATION_ON", "spa/filtration", "1")
    else:
        send_spa_command_verified("FILTRATION_OFF", "spa/filtration", "0")

elif topic == "spa/cmd/temp_10":
    send_spa_command_verified("TEMP_10", "spa/setpoint", "10")

elif topic == "spa/cmd/temp_38":
    send_spa_command_verified("TEMP_38", "spa/setpoint", "38")

elif topic == "spa/cmd/color_white":
    send_spa_command_verified("COLOR_WHITE", "spa/couleur", "Blanc")

elif topic in ["spa/cmd/thermostat", "spa/cmd/consigne"]:
    # Compatible slider Jeedom : payload attendu = 10 à 40
    send_setpoint_thermostat(payload)

client.on_connect = on_connect
client.on_message = on_message

client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()

while True:

try:
    print("Connexion spa...")

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(30)
    s.connect((SPA_IP, SPA_PORT))

    with socket_lock:
        spa_socket = s

    buffer = b""

    print("Spa connecté")

    while True:

        data = s.recv(4096)
        if not data:
            raise Exception("disconnect")

        buffer += data
        buffer = buffer[-4096:]

        idx = buffer.find(b'\x1a\xff')
        if idx == -1:
            continue

        frame = buffer[idx:]

        if len(frame) < 24:
            continue

        # TEMP ENTIÈRES
        water_c = int(round((frame[9] - 32) * 5 / 9))
        setpoint_c = int(round((frame[16] - 32) * 5 / 9))

        send("spa/water_temp", water_c)
        send("spa/setpoint", setpoint_c)
        send("spa/thermostat", setpoint_c)

        send("spa/pompe_gauche", "1" if frame[12] & 0x04 else "0")
        send("spa/pompe_droite", "1" if frame[12] & 0x10 else "0")
        send("spa/blower", "1" if frame[14] & 0x08 else "0")
        send("spa/filtration", "1" if frame[17] & 0x80 else "0")
        send("spa/ozonateur", "1" if frame[14] & 0x01 else "0")
        send("spa/heater", "1" if frame[14] & 0x04 else "0")

        light_code = frame[17] & 0x0F

        send("spa/etat_lumiere", "1" if light_code != 0 else "0")
        send("spa/couleur", LIGHT_MAP.get(light_code, "MODE"))
        send("spa/couleur_hex", COLOR_MAP.get(light_code, "#000000"))

        send("spa/heure", datetime.now().strftime("%H:%M:%S"), force=True)

except Exception as e:
    print("TCP reconnect:", e)
    time.sleep(5)

finally:
    try:
        with socket_lock:
            spa_socket = None
        s.close()
    except:
        pass

Hi everyone,

quick update on the progress of the integration: I have just (pre-)released v2.0.0-rc3, which includes a couple of major additions and updates.

P20B29 Support Added

Huge thanks to @Yannickt26 for sharing your script. It was an absolute goldmine! Using it, I was able to fully implement the P20B29 model adapter in this release candidate. Yannick, if you'd like to give it a test and let me know if everything is working fine with your spa, that would be awesome!

One detail to notice is that I currently don't expose configuration switches to toggle between "Manual" and "Auto" modes for the heating and ozone systems on the P20B29. In your captures, byte 13 remained constant at 0x6F, and there were no commands in your script for configuring these modes.

Does your touchpad physically show options to switch between manual and auto heater/ozone modes? If yes, and you can toggle them, it would be super helpful to capture those transitions or let me know if they modify any bytes in the broadcast stream.

P25B37 Support Update

@tprommi and I are still working on integrating the P25B37 (see #57). If anybody else happen to have a P25B37, feel free to contribute :slight_smile:

Major Version Bump & Breaking Change (Light Switch Migration)

You might notice that the version has jumped to v2.0.0. This is because I moved the light switch entity to the native Home Assistant light platform.

  • Why the change? This was done to fully support the color-changing effects of the P25B37 topside controller.

  • Breaking Change: This will break any existing automations that referenced the old switch.spa_light entity, so you will need to update them to target light.spa_light. This is the only breaking change in this release.

If anyone else would like to test the release candidate and share their feedback, feel free to try it out! Let me know if you run into any issues.


Hello,

I'm glad if my script can be useful. I'm a beginner with Home Assistant and even more so with scripting. I'm using ChatGPT to adapt my script to my needs.
For now, I haven't needed the heating modes, which is why it doesn't include them, but it's something I'm considering for the future.
With your new integration, it correctly detects my spa model and tells me the integration was successful, but then it can't find the data. Perhaps it's because I'm not using the W610?

Hi @Yannickt26,

thanks for testing! Since the config flow successfully auto-detected your spa model as a P20B29, this means that the TCP connection works and HA is seeing a valid data stream from your RS485 bridge. There are no issues with the wiring, baudrate, or data formatting (we would have seen a different error then).

The No data from RS485 bridge error only happens afterwards when the integration tries to establish its persistent connection. My best guess is that your custom script is still running in the background and holding the TCP connection, creating contention with HA.

To resolve this, you could:

  • Option 1 (Deactivate the script): Stop your custom Python MQTT script completely, and then reload the integration in HA. This is the simplest way and should prevent command collision/bus contention.
  • Option 2 (Configure multiple TCP connections on the bridge): According to a quick research (AI :waving_hand:), your ZLAN5143D hardware should be capable of supporting multiple simultaneous TCP connections. If your bridge is currently rejecting or dropping the second connection, its settings might be configured to limit concurrent sessions to 1 or something like that. Could you take a look if you can configure it to allow multiple parallel connections? This will allow both the integration and your script to connect at the same time.

Hello,

My script was desactived when I tried yours.

I sniffed my communication regarding heating modes and was able to deduce commands that work for me.

STANDARD = [
bytes.fromhex("1A 01 30 10 3C A3 00 A1 62 05 19 17 1B 15 0C 1B 15 17 1B 15 B4 BE 2C 44 1D"),
bytes.fromhex("1A 01 30 10 3C A4 00 A1 52 0C 1B 15 17 1B 15 0C 1B 15 17 1B 15 AB C0 23 5B 1D"),
]

ECO = [
bytes.fromhex("1A 01 30 10 3C A3 00 A1 62 09 1B 15 11 00 0C 1B 15 17 1B 15 76 D8 8A 67 1D"),
bytes.fromhex("1A 01 30 10 3C A4 00 A1 62 10 00 11 1B 15 0C 1B 15 17 1B 15 B4 F9 9D AA 1D"),
]

STOP = [
bytes.fromhex("1A 01 30 10 3C A1 00 A1 00 00 80 80 02 04 00 32 00 34 22 13 8E 1D"),
bytes.fromhex("1A 01 30 10 3C A3 00 A1 52 0C 1B 15 17 1B 15 0C 1B 15 17 1B 15 F7 AC A2 8C 1D"),
bytes.fromhex("1A 01 30 10 3C A4 00 A1 62 0C 32 0D 00 0C 1B 15 17 1B 15 04 B6 87 A8 1D"),
]

Best regards

Hi @Yannickt26,

thank you again for the captures! I took another look and found the following:

  • The bug: I previously assumed that the P20 family used the tail-only unescaping policy (unescape_full_frame = False) similar to the P23 family. However, looking at the sniffed schedule frames, they contain escape sequences (1B 15 representing 0x1E) at lower indices (12, 14, and 16).
  • The impact: Because of the incorrect unescaping, all incoming broadcast frames failed the CRC validation check and were silently discarded. This caused the connection setup to time out and report "No data from RS485 bridge", while the config flow auto-detection succeeded because it did not validate the CRC.
  • The fix: I have set unescape_full_frame to True for the P20 adapter to decode full frames and pre-released 2.0.0-rc4 with this fix.

Feel free to try the new pre-release, any feedback appreciated :slight_smile:

Hello Alex,

With the latest update, I can see my spa, and the status updates correctly, but I can't send commands (starting the pumps, blower, and temperature). However, once I start the commands, they update correctly.

I can even leave my script running; yours doesn't interfere with its operation.

Best regards

Hi @Yannickt26,

Thanks for the feedback! I investigated the issue and might have found a solution.

First of all: I verified that the command bytes built by the integration are 100% identical to those in your script. The write issues were caused by two network/status-related root causes instead:

  1. TCP Socket Buffering: Because the RS-485 bus is highly active, the integration synchronizes writes to a precise 30ms quiet window after the sync frame. OS-level buffering (the AI insists in calling it "Nagle's algorithm") was delaying the commands, causing them to collide with subsequent broadcasts. Your script bypassed this by sending the command 10 times at 500ms intervals, allowing one of the writes to eventually hit a quiet window by chance.
  2. Status Parsing (UI Snap-Back): The P20 status parser was checking incorrect bitmasks and state lookups inherited from the P23/P25 adapters. This caused the integration to miss the updated state in broadcasts, resulting in a UI snap-back to "Off" in Home Assistant.

The Fix: I disabled the random delay (TCP_NODELAY) on the TCP socket and updated the P20 parser to check the raw 0x01 relay bit and the correct 0x20 base offset directly.

I have released v2.0.0-rc5 with these fixes. Please give it a try when you have a moment and let me know if it works!

Hi everyone,

First of all, I want to say a big thank you to Alex and Yannickt26 for this integration and for the work done on the Joyonway protocol. The integration is very well structured and it helped me a lot to get my spa controller into Home Assistant.

I managed to get a Joyonway P20B29 working through an USR-W610 RS485-to-TCP bridge.

My working bridge settings are:

  • RS485 / TCP transparent mode
  • 38400 baud
  • 8N1
  • TCP server mode
  • Port 8899

During testing, Home Assistant was receiving valid Joyonway frames, but the P20B29 adapter was not parsing them. The broadcast frames from my controller start with:

1A FF 01 3C D2 B4 FF 06 01

In the current P20 adapter, the expected signature was:

1A FF 01 3C D2 B4 FF 08 01

So I changed the P20B29 signature from:

P20B29_SIGNATURE = bytes([0x1A, 0xFF, 0x01, 0x3C, 0xD2, 0xB4, 0xFF, 0x08, 0x01])

to:

P20B29_SIGNATURE = bytes([0x1A, 0xFF, 0x01, 0x3C, 0xD2, 0xB4, 0xFF, 0x06, 0x01])

After this change, the integration started parsing the status correctly and all main entities appeared in Home Assistant.

I also noticed a small issue with the Heating switch state. On my P20B29, the heater byte can report 0x20 even when the spa is idle/off, so the Heating switch was coming back ON automatically in Home Assistant although the spa was not actually heating.

I changed the heater_enabled logic from a simple bit check:

"heater_enabled": bool(heater_byte & 0x20)

to a state-based check:

"heater_enabled": heater_base in (0x21, 0x24, 0x25)

After that, the Heating switch state behaved correctly.

So, in short, for my P20B29 variant, the two required fixes were:

  1. P20B29 broadcast signature: 0x08 → 0x06
  2. Heating switch state logic: avoid treating 0x20 idle/off as heating enabled

Thanks again, Alex. Great work on this integration!

Hey guys,

I have anew Softspa Legacy Jacuzzi which has a built in Wifi using the Joyonway App, showing PB565 for Spa as component in the App.

Any idea if I can integrate it, all the mentioned ports I found don´t work with the IP address (fixed) one.

Is there a chance to stear and read this? Or is even with the built in wifi an additional controller needed?

thx in advance

Hi memberx,

The port scan result is expected. On the 2024+ panels like your PB565, the WiFi transceiver is built into the touchpad itself and it only talks outbound to the Joyonway cloud servers. There is no local API and no open port on the LAN. The app works because both your phone and the spa go through their cloud.

So you have two realistic options:

  1. Reverse engineering the cloud protocol. Possible in theory, fragile in practice, and everything breaks the day they change their backend.

  2. Going hardware, which is what I did. The touchpad and the power box communicate over an RS485 bus. I tapped that bus with a serial-to-WiFi gateway (USR-W610, around 25 EUR) and reverse engineered the frames. The result is a local HACS integration, fully cloud-free: GitHub - KnapTheBuilder/ha-joyonway-p23b32: Home Assistant custom integration for Joyonway P23B32 spa via RS485 · GitHub

One caveat: my integration was built against the P23B32 power box. Your PB565 is a newer generation panel and the frame format on the bus may differ. Can you check the reference printed on your power box (the plastic box under the spa where all the cables converge)? If you are comfortable opening it, a photo of the board and its connectors would help.

If the protocol turns out to be different, capturing a few minutes of bus traffic with the USR-W610 in TCP server mode is enough to compare. I can help you analyze the frames if you post a capture.

To answer your last question directly: no additional Joyonway controller is needed, but a small piece of hardware (the RS485 gateway) is required if you want local control. The built-in WiFi alone will never give you that.

Hi @sergiu.balota,

Thank you very much for your feedback!

I implemented the fix based on your findings and pre-released 2.0.0-rc6. Feel free to test and provide feedback :slight_smile: If everything is working, I aim to release 2.0.0 soon.

PS: You said you did adjust the values yourself and tested successfully. Please do not hesitate to open a PR, it's very welcome! :rocket:

@Yannickt26 @tprommi and others: Your feedback would of course also be very valueable! :slight_smile:

Hello guys,

Sorry for my bad English. I have a problem with a Joyonway P25B85 (PCB) P2325B0003 R06, "P25B85-2022". I need to integrate this in Home Assistant with GitHub - alexbde/ha-joyonway: Home Assistant custom integration for Joyonway spas · GitHub.

However, I don't have a PB554 control panel; I only have a PB557. The CN24 and CN23 contacts are both unused. I can measure 1.4V between A and GND, and 1.8V between B and GND. So, it appears to be wired on the circuit board, yet I’m not receiving any data. My PB554 control panel is connected to CN25, which only has three contacts connected. Now, my question is: is the missing PB554 control panel the reason why communication isn't taking place? What can I do? Can I retrofit a PB554 and get it working? Or are there other options? There doesn't seem to be any usable data coming from CN25.

Thanks in advance, Andreas.