JoyOnWay Spa Control

I wanted to post the captured data from my spa here, but there's so much of it that processing errors occurred, so I decided against it to avoid unnecessary confusion. Instead, I started analyzing the material.
christopheknap
My approach to the ozonator and the 0xC1 flag was incorrect. Of course, it worked for me, but not necessarily for others.
And not only that, but you were right that it's definitely better to work with individual bits instead of whole byte values. I've modified my script, starting byte numbering from 0.

ozone - checking bit 0 and/or bit 7 in byte 14 will be correct.
LED - bit 0 in byte 17.
filtering - bit 1 in byte 12.
massage - bit 2 in byte 12.
heating: circulation bit 2 = 0, bit 4 = 1 in byte 14.
heating bit 2 = 1, bit 4 = 1 in byte 14.
cooldown - I have a problem here. You can read it from my code. It works for me, but I don't know if it will be portable to other configurations.

I've fixed another small bug. If anyone is interested, feel free to test it.

old-man
You can connect

My new script:

import socket
import json
import time
import paho.mqtt.client as mqtt

# Configuration constants
SPA_IP = "ADD_IP_SPA"
SPA_PORT = 8899

MQTT_HOST = "ADD_IP_MQQT (HA)"
MQTT_PORT = 1883

# Initialize MQTT Client using the updated API version
mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.username_pw_set("user_mqtt", "passw_mtt")


def f_to_c(f):
    """Convert Fahrenheit to Celsius and round to 1 decimal place."""
    return round((f - 32) * 5 / 9, 1)


def pseudo_unescape(data: bytes) -> bytes:
    """
    Handles byte-unstuffing for RS485 communication.
    Replaces 2-byte escape sequences starting with 0x1B back into original raw bytes.
    """
    out = bytearray()
    i = 0

    while i < len(data):

        if i + 1 < len(data) and data[i] == 0x1B:

            nxt = data[i + 1]

            if nxt == 0x11:
                out.append(0x1A)
                i += 2
                continue

            elif nxt == 0x13:
                out.append(0x1C)
                i += 2
                continue

            elif nxt == 0x14:
                out.append(0x1D)
                i += 2
                continue

            elif nxt == 0x15:
                out.append(0x1E)
                i += 2
                continue

            elif nxt == 0x0B:
                out.append(0x1B)
                i += 2
                continue

        out.append(data[i])
        i += 1

    return bytes(out)


def parse_frame(frame):
    """Parses telemetry data from the unescaped RS485 frame and publishes it via MQTT."""
    data = {}

    try:
        # Extract temperatures
        temp_f = frame[9]
        set_f = frame[16]

        data["water_temp"] = f_to_c(temp_f)
        data["target_temp"] = f_to_c(set_f)

        # Light - Check bit 0 in byte 17 (Mask: 0x01 -> 00000001)
        data["light"] = bool(frame[17] & 0x01)

        # Filtering - Check bit 1 in both byte 12 and byte 27 (Mask: 0x02 -> 00000010)
        data["filtering"] = (
            bool(frame[12] & 0x02) and bool(frame[27] & 0x02)
        )

        # Massage - Check bit 2 in both byte 12 and byte 27 (Mask: 0x04 -> 00000100)
        data["massage"] = (
            bool(frame[12] & 0x04) and bool(frame[27] & 0x04)
        )

        # Heating state logic based on specific bits
        f14 = frame[14]
        f17 = frame[17]

        # Circulation: in byte 14, bit 4 == 1 AND bit 2 == 0
        if (f14 & 0x10) != 0 and (f14 & 0x04) == 0:
            data["heating_state"] = "circulation"

        # Heating: in byte 14, bit 4 == 1 AND bit 2 == 1 (Combined mask: 0x14 -> 00010100)
        elif (f14 & 0x14) == 0x14:
            data["heating_state"] = "heating"

        # Cooldown: in byte 14, bit 0 == 0 AND bit 4 == 0, and in byte 17, bit 7 == 1 (Mask: 0x80)
        elif (
            (f14 & 0x01) == 0 and
            (f14 & 0x10) == 0 and
            bool(f17 & 0x80)
        ):
            data["heating_state"] = "cooldown"

        else:
            data["heating_state"] = "off"

        # UV - Check if both bit 0 and bit 7 in byte 14 are set to 1 (Combined mask: 0x81 -> 10000001)
        data["uv"] = (
            (f14 & 0x81) == 0x81 and
            frame[28] == 0x20
        )

        # Datetime extraction
        year = 2000 + frame[53]
        month = frame[54]
        day = frame[55]

        hour = frame[56]
        minute = frame[57]
        second = frame[58]

        data["spa_time"] = (
            f"{year:04d}-{month:02d}-{day:02d} "
            f"{hour:02d}:{minute:02d}:{second:02d}"
        )

        # Weekday mapping from byte 59 (0 = Sunday, 1 = Monday, etc.)
        weekdays = {
            0: "Sunday",
            1: "Monday",
            2: "Tuesday",
            3: "Wednesday",
            4: "Thursday",
            5: "Friday",
            6: "Saturday"
        }
        raw_weekday = frame[59]
        data["spa_weekday"] = weekdays.get(raw_weekday, "unknown")

        # Publish the finalized data dictionary as a JSON payload to Home Assistant / MQTT
        info = mqttc.publish(
            "spa/state",
            json.dumps(data),
            retain=True
        )

        print("MQTT RC:", info.rc)
        print(data)

    except Exception as e:
        print("Parse error:", e)


def main():
    # Connect to the MQTT Broker
    mqttc.connect(MQTT_HOST, MQTT_PORT, 60)
    
    # FIX: Start the background thread for MQTT only ONCE before entering the loop.
    # This prevents creating multiple infinite background threads on socket drops.
    mqttc.loop_start()
    print("MQTT background thread started successfully")

    while True:
        try:
            # Create a TCP socket to communicate with the RS485-to-Ethernet bridge
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.connect((SPA_IP, SPA_PORT))

            print("Connected to spa")

            buffer = bytearray()

            while True:
                # Receive raw chunk of data from the socket stream
                chunk = sock.recv(1024)

                if not chunk:
                    raise Exception("Disconnected")

                buffer.extend(chunk)

                # Process all complete frames currently available in the buffer
                while True:
                    try:
                        # Locate frame boundaries using markers (0x1A = Start, 0x1D = End)
                        start = buffer.index(0x1A)
                        end = buffer.index(0x1D, start)

                    except ValueError:
                        # Break inner loop if a full frame boundary is not yet available
                        break

                    # Extract the standalone raw frame
                    frame = bytes(buffer[start:end + 1])

                    # Clear the processed frame (including any preceding noise) from the main buffer
                    del buffer[:end + 1]

                    # Sanity check on minimum frame length
                    if len(frame) < 10:
                        continue

                    # Verify frame routing identifier
                    if frame[1] != 0xFF:
                        continue

                    # Remove byte-stuffing overhead before parsing fields
                    frame = pseudo_unescape(frame)

                    # Extract payload and ship via MQTT
                    parse_frame(frame)

        except Exception as e:
            print("Connection error:", e)
            # Wait 5 seconds before attempting to reconnect to the spa
            time.sleep(5)


if __name__ == "__main__":
    main()

And mqtt.yaml

sensor:
  - name: "Spa Water Temperature"
    state_topic: "spa/state"
    value_template: "{{ value_json.water_temp }}"
    unit_of_measurement: "°C"
    device_class: "temperature"
    state_class: "measurement"
    unique_id: "spa_water_temperature"

  - name: "Spa Target Temperature"
    state_topic: "spa/state"
    value_template: "{{ value_json.target_temp }}"
    unit_of_measurement: "°C"
    device_class: "temperature"
    unique_id: "spa_target_temperature"

  - name: "Spa Heating State"
    state_topic: "spa/state"
    value_template: "{{ value_json.heating_state }}"
    icon: "mdi:water-boiler"
    unique_id: "spa_heating_state"

  - name: "Spa Controller Time"
    state_topic: "spa/state"
    value_template: "{{ value_json.spa_time }}"
    icon: "mdi:clock-digital"
    unique_id: "spa_controller_time"

  - name: "Spa Day of Week"
    state_topic: "spa/state"
    value_template: "{{ value_json.spa_weekday }}"
    icon: "mdi:calendar-range"
    unique_id: "spa_day_of_week"

binary_sensor:
  - name: "Spa Light"
    state_topic: "spa/state"
    value_template: "{{ 'ON' if value_json.light else 'OFF' }}"
    device_class: "light"
    unique_id: "spa_light"

  - name: "Spa Filtering"
    state_topic: "spa/state"
    value_template: "{{ 'ON' if value_json.filtering else 'OFF' }}"
    device_class: "running"
    icon: "mdi:filter"
    unique_id: "spa_filtering"

  - name: "Spa Massage"
    state_topic: "spa/state"
    value_template: "{{ 'ON' if value_json.massage else 'OFF' }}"
    device_class: "running"
    icon: "mdi:hydro-power"
    unique_id: "spa_massage"

  - name: "Spa UV Sterilizer"
    state_topic: "spa/state"
    value_template: "{{ 'ON' if value_json.uv else 'OFF' }}"
    device_class: "safety"
    icon: "mdi:lightbulb-uv"
    unique_id: "spa_uv_sterilizer"

I created a folder named "testspa" and a file named "mqtt.yaml" under config. Then, I created a file named "testspa.py" inside the "testspa" folder and copied the content "new Script" into it. I restarted HA. However, I cannot see any data. What am I doing wrong?

From the beginning:
Leave mqtt for now; first, you need to have the data in the console.

In the testspa folder you already have, create an additional requirements.txt file inside folder with the contents of

paho-mqtt

Next:
For testing, it's best to install the "Advanced SSH & Web Terminal" add-on. Once installed, set the "Protection mode" option to "disable" and run it. Then open the user interface.

In the testspa.py file, enter the correct "ADD_IP_SPA" address in the section:

# Configuration constants
SPA_IP = "ADD_IP_SPA"
#SPA_IP= "192.168.1.110"
SPA_PORT = 8899

If you have mqtt in HA, enter the HA address in the section:

MQTT_HOST = "ADD_IP_MQQT (HA)"
#MQTT_HOST="192.168.1.100"
MQTT_PORT = 1883

Next, you'll need the mqtt username and password, but without them, it should still provide the data in the console.

Next, navigate to the testspa folder by entering the command:

cd testspa

Then enter the command:

python3 testspa.py

Show screenshot.

Hi KDy. I followed all the instructions exactly. I did end up having to install PaHo-mqtt after all, though. And lo and behold—data is coming through! You guys are doing a really great job.

MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:09', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:09', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:10', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:10', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:11', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:12', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:12', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:13', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:13', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:14', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:15', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:15', 'spa_weekday': 'Wednesday'}
MQTT RC: 4
{'water_temp': 35.0, 'target_temp': 36.7, 'light': False, 'filtering': False, 'massage': False, 'heating_state': 'off', 'uv': False, 'spa_time': '2026-05-27 18:54:16', 'spa_weekday': 'Wednesday'}

old-man
OK. Test if something works, if the status changes when turning on individual devices.

Hi @Yannickt26!

Thanks for trying it out! Unfortunately the integration is specific to the P25B85 controller — the byte layout, command frames, and state parsing are all different between Joyonway models (P20B29, P23B32, P25B85, etc.), so it won't work on yours as-is.

The good news is the codebase was designed with a model adapter pattern, so adding support for other controllers is possible without rewriting everything. It's mostly about mapping the right bytes for your hardware. I can only test on hardware I own (P25B85), but PRs for other models are very welcome! If you have RS485 captures from your P20B29, that's the starting point.

You might also want to check out christopheknap's ha-joyonway-p23b32. The P23B32 and P20B29 are both multi-equipment controllers, so his byte map may be closer to yours than mine.

Update on the P25B85 integration

Progress since last week:

  • CRC-32 fully cracked: Polynomial 0x04C11DB7 with word-swap, verified against 44 captured frames. All commands are now built dynamically, no replay tables needed.
  • Full write support: Temperature, pump (jets), light, heater, blower, ozone, schedules, clock sync. All via computed CRC.
  • Options flow: Configure ozone mode (Auto/Manual) and automatic clock sync from the HA UI.
  • Live testing started: Reads all working, jets and blower confirmed. Remaining writes in progress.

@KDy your bit-level analysis in #123 matches what I have exactly (byte 14 bits for ozone/heating, byte 12 for pump, byte 17 for light). Good to see convergence. Since we share the same hardware, my integration should work on your and @old-man's setup.

:warning: Alpha state: The integration is not yet officially released. Some write commands still need live verification at the spa. That said, testers are very welcome! If you have a P25B85 + PB554 and an EW11 or W610, feel free to install via HACS custom repo and report back.

Repo: GitHub - alexbde/ha-joyonway-p25b85: Home Assistant custom integration for Joyonway P25B85 spa via RS485 · GitHub

KDy
Test performed. The light status is detected, as are the temperature values. When the "Heat and Filter" program is active: The pump runs at Level 1; however, neither "Filtering" nor "Ozone" is displayed—only "Heating: On" or "Off."

alexbde
The integration installed quickly and displays various values ​​as well as the heating and filtering programs.
When the heating and filtering program is running, no pump speed is displayed to me—though the heating or ozone status is.
I switched on the jets via the control interface. Now the system displays "Jets: Low" and a status of "Off." The jets can no longer be switched off; the toggle switch keeps snapping back to its original position. The same issue occurs with the other switches as well.

Thank you very much for your tests and findings! I will verify and fix :slight_smile:

alexbde
Light – working
Jets – can be switched on, cannot be switched off
Heating – working
Ozone – inactive
Blower – I don’t have one, so I can’t check
Filter slot 1, filter slot 2 – these buttons switch filtration on/off regardless of the set time
Heat slot 1, heat slot 2 – not working (does not switch on at the set time)

Statuses, date, time – everything works; the ozone status also works, but I haven’t checked all cases.

For the first filtration slot, it incorrectly overwrites the start time; for the first and second heat slots, it incorrectly overwrites the end time of heating (the time on the panel does not match the time in the integration) – perhaps that is why they do not switch on

At some point, the ozonation settings changed to AUTO; I do not know what caused this switch.

If I run something from your integrations, it completely messes up the reading of my script.

And one more thing: how do you send frames to the controller? I get the impression that something is wrong on the RS485 bus. Everything takes a long time; often you have to repeat the on-off cycle several times for it to work.

old-man

I’m not sure if I’ve understood correctly – you say the ‘Heat and Filter’ programme is active. Are you activating it from some sort of app? If so, it won’t work. It needs to be run directly from the control panel. My approach is quite different. Everything in the jacuzzi needs to be set to manual, and I want to handle the rest using Home Assistant automations.

KDy
I have disconnected my external Wi-Fi module and put the USR-W610 into operation via the COM interface.
I have configured the filtration and heating schedules on the control panel. These are also displayed within alexdbe's integration.
I modified the schedules within alexdbe's program, and the changes were successfully applied.
I always test your version and alexdbe's version individually, deactivating the other one during the process.
old-man from Germany

alexbde
There's a problem. Using your integration completely changed the configuration of my jacuzzi. I no longer saw the frames I was recording previously, only the changed ones. I also couldn't change the settings back from the panel, for example, the ozone setting was always set to A, even though I had set it to M. Even disconnecting the power didn't help. Only a factory reset helped.

Hi @KDy and @old-man,

Thank you both so much for testing the integration and sharing your detailed feedback! I really appreciate you taking the time! This is exactly the kind of real-world testing I needed.

@KDy, I'm very sorry about the factory reset :confused: I sincerely apologize that the integration corrupted your spa configuration. That should absolutely never happen. I'm treating this as the highest priority issue.

What I believe happened: The integration has an auto clock sync feature and an ozone mode setting that can send write commands to the spa automatically (without you explicitly pressing a button in HA). On your spa, these automatic writes likely conflicted with your panel settings and overwrote your configuration. This is a serious design flaw that I'm fixing immediately.

What I'm doing to fix it:

  1. Removing all automatic writes on startup/reload — the integration will ONLY send commands when you explicitly press a button in HA. No more silent writes.
  2. Adding safety guards to schedule commands — if the integration doesn't have fresh data, it will refuse to send rather than risk overwriting with wrong values.
  3. Disabling auto clock sync by default — it will be opt-in only, with clear warnings.

Specific issues you reported (KDy):

Issue Status Plan
Jets ON works, OFF doesn't Investigating May be a state-tracking issue, saw that before. I'll add fallback logic and better logging
Ozone inactive Known UX issue The ozone switch requires changing an option to "Manual" first. This is already fixed in the new version
Filter slot switches toggle pump Investigating The enable/disable may be immediately triggering filtration on your firmware variant
Heat slots don't activate Investigating May be related to the overwrite issue, if enables were accidentally disabled
Filter slot 1 overwrites start time Confirmed bug The schedule command resends ALL slot values. If data was stale, it overwrites with wrong values. Adding guards.

@old-man, thank you for the confirmation!

Great to hear that the schedule modifications worked on your side! The pump speed not showing during heating/filtering is a known limitation. The controller internally drives the pump for those functions but byte 12 (pump status) stays at 0x00. At least in my display, it is also not showing the jets activate but the little blue icon bottom left corner. Is it different for you?

Next steps

I'm working on a safety patch now that ensures:

  • No writes happen automatically - only when you explicitly interact with an entity
  • Schedule commands validate their data before sending
  • Better error messages when something can't be done

I'll post here when the fix is pushed. In the meantime, if you want to keep testing reads (sensors, states, temperatures), those are completely safe - only the write entities (switches, fan, time pickers, buttons) can modify the spa.

Again, sorry for the trouble @KDy! I'll make sure this can't happen again.

Best,
Alex

Hi everyone,

A few updates from my side.

== ha-joyonway-p23b32 v0.3 ==

New release available via HACS. Same conservative design philosophy:

  • Sensor + binary_sensor + button platforms only
  • No automatic writes, no startup writes, no auto clock sync
  • Commands only fire on explicit user action
  • W610 single-TCP-client constraint respected (SCAN_INTERVAL 60s)
  • CRC-8 (poly=0x07, init=0x71) validated against KDy's oscilloscope
    capture and Gaet78's P69B133 reference frames

For users who want to write to the bus, the recommended pattern is
shell_command + atomic single-byte commands triggered from automations,
never read-modify-write of multi-byte blocks. This is exactly the
class of bug that caused factory reset incidents on other integrations.

HACS repo: GitHub - KnapTheBuilder/ha-joyonway-p23b32: Home Assistant custom integration for Joyonway P23B32 spa via RS485 · GitHub

== Frame analyzer ==

The GitHub Pages deployment is currently down (404). I'm aware, fix in
progress, should be live again shortly.

Source repo stays available in the meantime:

You can clone it and open index.html locally if you need it urgently.

== For @mfo38 (P69B133 + PB563 + PAC in parallel) ==

Your capture looking like noise is consistent with what I'd expect when a
secondary device (your PAC controller) is co-driving the RS485 bus
alongside the WiFi card on CN24. The PAC and the WiFi module may be
contending for the bus, corrupting frames in both directions.

Two things worth trying:

  1. Power-off the PAC briefly and capture again on CN24 with PAC unplugged
    from the splitter. If the frames suddenly look clean (1A...1D
    delimiters appearing), the PAC is the noise source.

  2. Try plugging the W610 directly on CN23 (display panel side) instead of
    CN24, with the original WiFi card removed. CN23 typically carries
    cleaner display-controller traffic without PAC interference.

P69B133 uses 0x7E delimiter at 115200 baud (Gaet78's reference). Make
sure your W610 is configured for 115200 baud when listening on this
controller, not 38400.

== For @KDy ==

Your bit-level analysis in #123 is rigorous, and Alex confirming the
exact same bit mapping in #129 is great validation. Glad your script is
public, and sorry about the factory reset. The hours you put into the
oscilloscope work and the pseudo-escape decoding shouldn't end with a
broken panel.

== For @alexbde ==

Constructive note on your safety patch: when refactoring the schedule
command, consider switching from "write full slot block" to "write
single targeted byte" wherever the protocol allows it. Atomic single-byte
writes eliminate the read-modify-write race entirely. Happy to share my
write patterns from the P23B32 if useful.

== For @old-man ==

Confirming your observation: byte 12 stays at 0x00 when the controller
internally drives the pump during heating or filtering. To get a true
"resistance firing" indicator in HA, cross-reference an external power
meter on the spa supply (threshold ~2000W) with the heating bit. Works
reliably on my P23B32.

Christophe

Hi @christopheknap,

Thanks for the thoughtful advice on schedule safety! Always good to get a second pair of eyes on this stuff.

Your point about read-modify-write races is well taken. On the P25B85, the schedule commands (0xA3/0xA4) unfortunately require the full time block for both slots in every write, so I haven't found a way to do atomic single-byte writes here. What I ended up doing is freshness gating: before any schedule write, the integration checks that the last broadcast arrived within the last 5 seconds, and simply refuses the write if it can't confirm fresh data. Together with a completeness check that won't send if any schedule value is missing, this should keep things safe. If you have captures showing single-byte schedule writes working on the P23B32, I'd genuinely love to see them! Maybe there's a command variant I haven't discovered yet.

About @KDy's factory reset: I do feel bad about that, and I've taken it seriously. From what I can reconstruct, the culprit was an ozone mode sync that fired on startup in an older version. That's long gone now. For anyone reading who might be worried: a factory reset on these controllers just means re-entering your preferences (schedules, setpoint, ozone mode) on the panel. No hardware damage, nothing permanent. Still shouldn't have happened though, and the integration now strictly refuses to write anything without an explicit user action.

Also, congrats on integrating the CRC algorithm, nice to see it working across models! One small note on the README: the P25B85 support claim might need a caveat. Our controller uses model byte 0x03 (vs 0x02 for P23B32) and a different pump layout (single dual-speed vs left/right), so the broadcast parsing and replay frames would need adaptation to truly work on P25B85 hardware.
Thanks again for the constructive feedback, and glad to see the community growing around these controllers!

Quick heads-up: I'm in the middle of reworking the integration with stability and resilience as the main focus. I want to make sure every write command is thoroughly tested on my own spa before putting out a first release. I'll post here once I'm confident it's solid enough for others to try!

Best,
Alex

I've installed your new integration and will be testing it over the next few days.

Hello @christopheknap

Thanks for taking the time to respond to my specific use case.

To be sure we are on the same page, let me summarize bellow my current setup and what I have already tried:

  • On CN23 I have only the PB563 display that includes the Wifi card
  • On CN24 I have a splitter that goes on one hand to the PAC control cable and on the other hand to the W610
  • I tried already to put the W610 alone on CN23, that didn’t do any good since the frames received by the W610 dropped to zero, which is normal since nothing is sending them if the display is unplugged.
  • I can certainly try to unplug the splitter on CN24 and replace it with the W610 alone to see if I am getting clean frames that are accepted by @Gaet78‘s integration

What might be a working setup for my situation in your view ?

Have the display & W610 both plugged on CN23 with the splitter and the PAC alone on CN24?

I’ll try a few scenarios on the SPA as soon as I find time and will report back.

Thanks again to the whole community!

I’ve done a bit of testing. I configured the "Filter and Heater" programs on the control panel and set the ozone generator to automatic mode. The status display shows "Heating" while the unit is warming up, and switches to "Ozone" once the target temperature is reached. When no program is active, it displays "Off." That all seems to be working correctly. The current temperature is displayed, and the setpoint can be adjusted. However, I am having an issue with the jets. When a program is running, the display shows "Off"; when I switch the jets on, it indicates "Light." Yet, I am unable to switch them off again. How can I select Level 2?

@mfo38 Thanks for the clarification on the topology. Now I understand
your setup correctly:

CN23: PB563 display + integrated WiFi card
CN24: splitter -> PAC control + W610

You're right that putting the W610 alone on CN23 yields zero frames:
the display is the bus master on that port, so removing it kills the
traffic. Same logic applies in reverse on CN24.

The cleanest test for diagnosis is therefore:

Step 1 (5 min, no rewiring needed):
Keep your current setup. Power-off the PAC controller for 60 seconds
(breaker or unplug). Capture on the W610 during this window:
timeout 60 nc 192.168.1.X 8899 | xxd > pac_off_capture.txt
If frames suddenly look clean with 7E delimiters appearing
consistently, the PAC was the noise source. Power it back on.

Step 2 (if Step 1 confirms PAC interference):
Move the W610 from CN24 to CN23 via a new splitter
CN23: PB563 display + W610 (with splitter)
CN24: PAC control alone (no splitter)
This way, the W610 sees only display <-> controller traffic, with
no PAC contention on its segment. The PAC keeps its dedicated bus
segment on CN24, both bus segments share the same controller as
endpoint.

Note: the P69B133 protocol runs at 115200 baud with 0x7E delimiter
(Gaet78's reference). Confirm your W610 is configured for 115200
baud, not 38400 (which is P23B32). A wrong baud rate would also
produce noise-looking captures regardless of PAC interference.

Report back the result of Step 1 first, it's the fastest diagnostic.

@old-man Great that the heating cycle and ozone automatic mode are
working correctly. The jets behavior you describe is a separate
question and I need a bit more info before answering precisely.

A few clarifying questions:

  1. Which controller model do you have? It's marked on the main
    control box (P23B32, P25B85, P69B133, PB563, etc.) or on the
    keypad (PB554, PB555, PB563 etc.). Knowing this is essential
    because jet level handling differs across models.

  2. When you say "the display shows Off" for jets while a program
    is running, do you mean the integration's HA entity shows off,
    or the physical spa keypad itself displays Off?

  3. For "Light" appearing when you switch jets on, is this the same
    keypad button doing double duty (jets + light on one button via
    short-press vs long-press), or are these separate buttons?

  4. Most Joyonway jet pumps have two speeds. On PB554/PB555, you
    typically tap the jets button once for low speed, twice for
    high speed, third tap for off. On other panels it can be a
    long press. Have you tried tapping the jets button twice in
    quick succession?

  5. If you can share a 60-second RS485 capture from your bus during
    a jet on/off sequence, I can decode which command frames your
    panel sends and tell you exactly what HA service call you would
    need to replicate the level-2 toggle. Procedure:
    timeout 60 nc <W610_IP> 8899 | xxd > jets_capture.txt
    Make sure no Joyonway integration is actively polling the W610
    during this window, only one TCP client allowed.

Send me the model number and ideally a capture, and I'll trace the
exact frame for level 2.