Hayward AquaLogic / ProLogic automation

@mrmoorey , Please give this a try. “It worked for me” but we know how that goes :smiley:

This is actually not a change to the component, but it will protect you from changes that I’m working on (I might ask your help again to test them :smiley: ).

external_components:
  - source: github://b3nj1/[email protected]
    components: [rs485_frame]

This shouldn’t have the compile issues and has the i=2 offset:

packages:
  rs485:
    url: https://github.com/b3nj1/rs485_frame-examples
    ref: v3.0.2
``

It is looking good! Thanks for your help, and sorry for taking up so much of your afternoon. We have no missing characters, and the temperature sensors now populate.

I’ll update you on the menus and how they display; I’ve already noticed a couple of visual anomalies. Would you prefer I report issues here, or use Github?

@mrmoorey , great! Let’s move to GitHub in this repo: GitHub - b3nj1/rs485_frame-examples: Packaged ESPHome configurations for Hayward AquaLogic, Jandy AquaLink, and others to work with the RS485 Frame hub component (rs485_frame) for DLE-framed RS-485 buses. · GitHub for the menu and their display issues.

Will do. I’ll work on my dashboard, and then I’ll post to GitHub as I continue testing. I will leave the sniffer running in case there’s something you need to see at the frame level, and it will be easier to coordinate. Have a great rest of your day. Thanks again.

Thanks for all the feedback!

I’ve made few changes:

  • added support for esp-idf
  • removed ArduinoJson as it was left over from my initial implementat
  • added capability to configure key type for each key, it will now support all types that swilson identified. I’ve defaulted it to wireless2 for now, but now each key can be customized (for both send and toggle)
  • added binary sensors for the rest of flags that are available from the controller
    on_press:
      - aqualogic.send:
          key: MENU
          type: LOCAL_WIRED

Thanks. I’ve switched over to the @b3nj1 implementation, but I still have a backup of your code. I will test it out separately.

@b3nj1 I have been working on the menu unlock functionality; I could not get the left-right button to work as it was, but with the help of your sniffer, and some AI help, we have it working. It turns out on my Aqua Logic v2.85 left and right are 2-bytes, but left+right is sent as 4-bytes. I’m interested to hear your comments.

From the physical controller:

 1 @ 00 02 05 00 01 00  |......|
61 @ 00 02 05 00 00 00  |......|
 1 @ 00 02 05 00 05 00  |......|

I’m not sure about the purpose of the 05 00 01 00 frame. We implemented a latching switch that sends the left+right every 100ms for five seconds, then sends the release. It works perfectly on my system, and then the menu navigation key strokes work as is.

50 @ 00 02 05 00 00 00  |......|
 1 @ 00 02 00 00 00 00  |......|
0103 payloads:
  3 @ 01 03 20 43 6F 6E 66 69 67 75 72 61 74 69 6F 6E 20 20 20 4D 65 6E 75 2D 55 6E 6C 6F 63 6B 65 64  |.. Configuration   Menu-Unlocked|
  4 @ 01 03 20 43 68 6C 6F 72 2E 20 43 6F 6E 66 69 67 2E 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |.. Chlor. Config.               |
  3 @ 01 03 20 43 68 6C 6F 72 2E 20 43 6F 6E 66 69 67 2E 20 2B 20 74 6F 20 76 69 65 77 2F 63 68 61 6E  |.. Chlor. Config. + to view/chan|
  4 @ 01 03 50 6F 6F 6C 2F 53 70 61 20 43 6F 6E 66 69 67 2E 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |..Pool/Spa Config.              |
  4 @ 01 03 50 6F 6F 6C 2F 53 70 61 20 43 6F 6E 66 69 67 2E 2B 20 74 6F 20 76 69 65 77 2F 63 68 61 6E  |..Pool/Spa Config.+ to view/chan|
  4 @ 01 03 20 46 69 6C 74 65 72 20 43 6F 6E 66 69 67 2E 20 2B 20 74 6F 20 76 69 65 77 2F 63 68 61 6E  |.. Filter Config. + to view/chan|
  3 @ 01 03 20 46 69 6C 74 65 72 20 43 6F 6E 66 69 67 2E 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |.. Filter Config.               |
  5 @ 01 03 48 65 61 74 65 72 31 20 43 6F 6E 66 69 67 2E 20 2B 20 74 6F 20 76 69 65 77 2F 63 68 61 6E  |..Heater1 Config. + to view/chan|
  5 @ 01 03 48 65 61 74 65 72 31 20 43 6F 6E 66 69 67 2E 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |..Heater1 Config.               |
  8 @ 01 03 20 53 6F 6C 61 72 20 43 6F 6E 66 69 67 2E 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |.. Solar Config.                |
  7 @ 01 03 20 53 6F 6C 61 72 20 43 6F 6E 66 69 67 2E 20 20 2B 20 74 6F 20 76 69 65 77 2F 63 68 61 6E  |.. Solar Config.  + to view/chan|
  4 @ 01 03 20 20 20 20 20 53 6F 6C 61 72 20 20 20 20 20 20 20 20 20 20 45 6E 61 62 6C 65 64 20 20 20  |..     Solar          Enabled   |
  4 @ 01 03 20 20 20 20 20 53 6F 6C 61 72 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20  |..     Solar                    |
0002 payloads:
  5 @ 00 02 01 00 01 00  |......|
  1 @ 00 02 20 00 20 00  |.. . .|

The following code is in my aqualogic.yaml, as I bring your code in as an import for my own device. I’m sure you can put this where it makes sense for your personal implementations.

@AndreiR I would also think you can do a similar emulation within your codebase if you are interested.

# 1. Establish the type-safe template switch (Completely vanilla ESPHome, no custom component code)
switch:
  - platform: template
    name: "Menu Unlock Hold State"
    id: menu_unlock_hold_state
    optimistic: true

# 2. Execute the hold of left-right and submit to bus every 100ms
script:
  - id: execute_hold_loop
    mode: restart
    then:
      - while:
          condition:
            - switch.is_on: menu_unlock_hold_state
          then:
            - rs485_frame.send_frame:
                id: pool
                frame_type: [0x00, 0x02]
                # Changed to the exact 4-byte physical capture payload
                payload: [0x05, 0x00, 0x00, 0x00]
            - delay: 100ms

# 3. Create a button that triggers the button hold for five seconds, then releases
button:
  - platform: template
    name: "Trigger Menu Unlock Sequence"
    id: trigger_menu_unlock_sequence
    on_press:
      then:
        - switch.turn_on: menu_unlock_hold_state
        - script.execute: execute_hold_loop
        - delay: 5s
        - switch.turn_off: menu_unlock_hold_state
        - rs485_frame.send_frame:
            id: pool
            frame_type: [0x00, 0x02]
            # Changed to the exact 4-byte idle payload
            payload: [0x00, 0x00, 0x00, 0x00]

@mrmoorey ,
There are elements of your unlock that match mine. This is what I say in the device yaml:

      # Menu Unlock — Hayward unlocks setup menus by holding Left+Right for several seconds,
      # not a quick press. Two buttons bracket the hold instead of one: "Start" sets the
      # Left+Right bits with nothing released (held), "End" releases them. Press Start, wait
      # a few seconds, then press End.
      # - path: hayward/aqualogic/button.yaml
      #   vars: { button_name: "Menu Unlock Start", button_command: [0x05000000, 0x00000000] }
      # - path: hayward/aqualogic/button.yaml
      #   vars: { button_name: "Menu Unlock End", button_command: [0x00000000, 0x05000000] }
      # Service — wired local frame (0x0002), no postamble. Disabled by default:
      # maintenance use only; most users should not trigger this accidentally.

You see this too. 0x0500 is left (0x0400) + right (0x0100). You have to hold them down a while, so this is why you see “05 00 00 00” - the first 05 00 is pressing it down, but there is no release so it’s followed by “00 00”. For “normal” commands, they send a press + release together (left single press is 04 00 04 00). Your “05 00 01 00” and “05 00 05 00” probably both signifies a release of the double press.

The way I handle this in the commented out example yaml is split it in two buttons “Start” then “End”. In practice, I don’t need to press the End. I just press Start then the menu says it’s unlocked soon after, and I can begin mashing the other buttons like left/right., and it works.

BTW - thanks for filing the detailed bugs. I’ll try to look at them soon. We’ll have to see if it make sense to have split configs or a mode that make short vs long commands and frame types easier to mix/match.

I probably shot myself in the foot, because I made the incorrect assumption that since left and right were 2-byte messages, left+right would be too. I tried your code and it did not work, because I shortened the sequences to 2-bytes. In the end, the script is a little more precise and works reliably for me. To be honest, I should have just tested this from the physical device before I did anything else. Lesson learned.

Anyone integrating your codebase now has a couple of options if they want to script configuration menu settings.

@mrmoorey ,
Thanks for the context, and your example sequence usage is good. We should include that in the example yaml. Could you post your full / latest yaml into one of the bugs (if you didn’t already)?

The byte count friction isn’t great for cases like yours where there is variation. I’m going to play with an alternate form where you can just use the frame as a string as printed by the sniffer: “00 02 05 00 00 00”. The string length implies tbe byte count.

The current form is only convenient when the frame type (00 02), data size (pair of 2 bytes) is common across a majority of buttons. It might just not be worth the confusion it causes.

The observation has been that service, menu, plus, minus, left, right, and pool lights are all 2-bytes, all the others which affect aux relays or valves are 4-bytes, and the exception being the left-right combination. Perhaps you have a optional includes for button configurations for old-school and modern implementations? Unless you encounter a user with an even older firmware than I have, the variations could be very minor. I will add my latest configuration to GitHub. Thanks, as always.

Edit. FYI here’s my dashboard as it stands. I changed my display to split the text string at 16 bytes to create the 2-line display. The ESPHome device has the button for left+right for now.

@mrmoorey , here’s mine as seen on my phone.

For buttons like filter, I make the LED status and the button combined. Here’s my yaml for the section with the display down to the lights (plus my “solar” heater stuff which is just a zigbee switch controlling a 1/2 HP pump).

type: grid
cards:
  - show_name: false
    show_icon: false
    show_state: true
    type: glance
    entities:
      - entity: sensor.pool_controller_display
    grid_options:
      columns: 12
      rows: 1
  - type: vertical-stack
    cards:
      - square: false
        columns: 3
        type: grid
        cards:
          - type: custom:mushroom-entity-card
            entity: button.pool_controller_menu
            icon_type: icon
            name: Menu
            secondary_info: name
            layout: horizontal
            primary_info: none
            icon: mdi:menu-open
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_menu
          - type: custom:mushroom-entity-card
            entity: button.pool_controller_plus
            icon_type: icon
            layout: vertical
            secondary_info: none
            icon: mdi:plus
            primary_info: none
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_plus
          - show_name: false
            show_icon: false
            show_state: true
            type: glance
            entities:
              - entity: sensor.pool_controller_display_blink
          - type: custom:mushroom-entity-card
            entity: button.pool_controller_left
            icon_type: icon
            layout: vertical
            secondary_info: none
            icon: mdi:menu-left
            primary_info: none
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_left
          - type: custom:mushroom-entity-card
            entity: button.pool_controller_minus
            icon_type: icon
            layout: vertical
            secondary_info: none
            icon: mdi:minus
            primary_info: none
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_minus
          - type: custom:mushroom-entity-card
            entity: button.pool_controller_right
            icon_type: icon
            layout: vertical
            secondary_info: none
            icon: mdi:menu-right
            primary_info: none
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_right
      - square: false
        columns: 3
        type: grid
        cards:
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_pool_mode
            name: Pool
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_pool_spa_mode
            primary_info: none
            secondary_info: name
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_heater
            name: Heater On
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_heater
            secondary_info: name
            primary_info: none
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_heater_auto_mode
            name: Heater Auto
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_heater
            secondary_info: name
            primary_info: none
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_spa_mode
            name: Spa
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_pool_spa_mode
            primary_info: none
            secondary_info: name
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_blower
            name: Blower
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_blower
            secondary_info: name
            primary_info: none
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_uv
            name: UV
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_uv
            secondary_info: name
            primary_info: none
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_filter
            name: Filter
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_filter
            secondary_info: name
            primary_info: none
          - type: custom:mushroom-entity-card
            entity: binary_sensor.pool_controller_lights
            name: Lights
            tap_action:
              action: call-service
              service: button.press
              data: {}
              target:
                entity_id: button.pool_controller_lights
            secondary_info: name
            primary_info: none
    grid_options:
      columns: 12
      rows: 5
  - square: false
    columns: 3
    type: grid
    cards:
      - type: custom:mushroom-entity-card
        entity: switch.pool_solar_heater
        name: Solar Auto
        secondary_info: name
        primary_info: state
        tap_action:
          action: call-service
          service: switch.toggle
          data: {}
          target:
            entity_id: switch.pool_solar_heater
      - type: custom:mushroom-entity-card
        entity: switch.pool_solar_heater_pump
        name: Solar Pump
        secondary_info: name
        primary_info: state
        tap_action:
          action: call-service
          service: switch.toggle
          data: {}
          target:
            entity_id: switch.pool_solar_heater_pump
      - type: custom:mushroom-number-card
        entity: input_number.pool_solar_water_target_temperature
        name: Target
        secondary_info: none
        primary_info: none
        display_mode: buttons
        fill_container: false
        layout: horizontal
        icon_type: none
column_span: 1

2-part post…

Part 1… Public Service Announcement… think twice before running a long RS485 connection to your AquaLogic board. For.the last 18 months I have been connecting my PL4 board to a server via a RS485 USB adapter and about 70ft of CAT6 cable (up about 8ft outside, 8ft semi-outside enclosed attic, and another ~50ft through “indoor” attic)… about 13 months ago, a lightning storm fried my Hayward mainboard AND the display/control panel, plus my USB dongle and the USB hub it was attached to. And, this past weekend, it happened AGAIN–pretty sure at least the board is fried but won’t know for sure until both a new board and new control panel PCB arrive. Getting off this configuration ASAP.

Part 2… Super excited to see the ESPHome solutions here. I always thought an ESP8266 or ESP32 was the right answer for this, I just thought it would have to be micropython. Ordering me a Waveshare RS485 ESP32 board with the other parts so I can make the switch.

Edit… Meant to say, the lightning isn’t even hitting me, just striking nearby. The cable seems to be turning into a giant EMP antenna. If you must do this, maybe use shielded cable and ground both ends VERY WELL.

That’s a cool way of doing it. I tried to mimic the more simplistic display of the Aqua Logic - primarily being able to see the status lights, temperature, chlorinator status, and salt levels, and to remotely turn the pool lights on. The timer on my system is very basic, so I can’t have multiple programs like I had on the Jandy Aqualink at my old house.

The only automatic valve is the solar, so I have to manually adjust the valves for the skimmer, and the combined suction (pool sweep) and main drain. It will be nice to automate these in the future, but we’ll make do for now.

I am looking into automation for changing the chlorinator percentage now, as I adjust it depending on the outdoor temperature, and how much we’ve been swimming. I was thinking that by scripting the whole configuration workflow, you could make life very easy for a pool installer to set up all the parameters on a new pool install, or even to dump out the system configuration without having to click through all the menus and sub-menus.

It has been a fun adventure getting this working, and it is amazing how much cheaper it is to do it this way than pay for a whole OEM automation system - especially given how old my pool controller is. I will be moving to my Navien water heater next, as I can use another Waveshare device.

Thanks!

I never needed to unlock the menu (most of the settings that I change are outside of the locked menu), but I’ve added this.

  • Confirmed to be working with latest ESPHome 2026.7.3
  • Support for sending press / release independently
  • Ability to set 4 byte vs 2 byte local commands (not tested as my controller uses 4 byte local commands)
  • new UNLOCK button which is combination of RIGHT + LEFT

Example of how to send UNLOCK

  - platform: template
    name: "Unlock Configuration Menu"
    on_press:
      - aqualogic.press:
          key: UNLOCK
      - delay: 5s
      - aqualogic.release:

@b3nj1 Question on the sniffer. Despite the comment in your code that:

# Capture more bytes per payload than the 32-byte default: Hayward display frames run
# ~32 bytes and we want to see the whole thing in the hex+ASCII dump.
payload_capture_bytes: 48

The periodic sniffer output appears to be limited to 32 bytes, and the display text is truncated by two bytes.

01 03 20 46 69 6C 74 65 72 20 48 69 2D 61 6C 6C 20 20 20 38 3A 30 30 41 20 74 6F 20 30 36 3A 34  |.. Filter Hi-all   8:00A to 06:4|
01 03 20 46 69 6C 74 65 72 20 48 69 2D 61 6C 6C 20 20 30 38 3A 30 30 41 20 74 6F 20 30 36 3A 34  |.. Filter Hi-all  08:00A to 06:4|
01 03 20 46 69 6C 74 65 72 20 48 69 2D 61 6C 6C 20 20 20 20 20 20 20 20 20 74 6F 20 30 36 3A 34  |.. Filter Hi-all         to 06:4|
01 03 20 46 69 6C 74 65 72 20 48 69 2D 61 6C 6C 20 20 30 38 3A 30 30 41 20 74 6F 20 20 20 20 20  |.. Filter Hi-all  08:00A to     |
01 03 20 46 69 6C 74 65 72 20 4C 6F 2D 61 6C 6C 20 20 20 20 20 20 20 20 20 74 6F 20 30 35 3A 33  |.. Filter Lo-all         to 05:3|
01 03 20 46 69 6C 74 65 72 20 4C 6F 2D 61 6C 6C 20 20 30 39 3A 31 35 41 20 74 6F 20 30 35 3A 33  |.. Filter Lo-all  09:15A to 05:3|

This is the “dump_frames” output for the last line of the sniffer output, for example:

'Display' >> ' Filter Lo-all  09:15A to 05:30P'
RX 01032046696c746572204c6f2d616c6c202020202020202020746f2030353a33305000

You can see the packet dump is truncating the ‘0P’ characters from the string. How can I remedy this to see the entire packet in the sniffer?

@mrmoorey ,
I’ll try to reproduce and get back to you. In the meantime, can you add the full yaml with the sniffer settings including the 48B capture to the githup thread we have? I’ll double check that too.

Edit: I wrote a test with your long input, and it produced the right histogram. Maybe the yaml will help me spot the difference.

Done. Thanks.

@mrmoorey , I see the confusion. The default is 32 in the component. The example you found sets it to 48, but you’re not including that example. Your yaml does not set it, so it gets 32. Add it to your yaml like this:

rs485_frame:
 - id: !extend pool
   dump_frames: false
   sniffer_stats: { interval: 30s, max_frame_types: 64, payload_dump_top: 20, payload_capture_bytes: 48 }

Let me know if that doesn’t work.