Reading ORP/PH from old Watermatic/Jandy Chemlink1500

Hi there, I was bummed that the old watermatic chemical automation data does not come through the new iAqualink API. I have a chemlink 1500 that been in service for about 10 years now. I wanted to see and track ORP and PH, along with when the dosing pumps were running. AqualinkD can do this I think (and more robustly I’d imagine), but I just needed this limited set of data, and I wanted to use an ESP32 device that could fit inside my aqualink LCD panel.

I used an M5 Atom Like and a Tail485 RS485 interface. It’s neat because it will allow use of the 12v power on the bus to power the Atom. The whole package is 5x2.5x1cm.

Connection:
B - Wire #3
A - Wire #2
12+ - Wire #1
Ground - Wire #4

I vibe coded with chatgpt5 (grok4 and gemini pro were dissapointing and chased a lot of rabbits to ultimate disaster). I imagine this code is super inefficient, but it works. The byte pattern was non trivial and beyond my paygrade, so I leaned on the AI.

esphome:
  name: orpmonitor
  friendly_name: ORPMonitor

esp32:
  board: esp32dev
  framework:
    type: esp-idf

external_components:
  - source: github://eigger/espcomponents@latest
    components: [ uartex ]
    refresh: always

# Enable Home Assistant API
api:
  encryption:
    key: XX

ota:
  - platform: esphome
    password: XX

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Orpmonitor Fallback Hotspot"
    password: XX

captive_portal:

logger:
  baud_rate: 0  # Disable logger UART conflict
  level: DEBUG

uart:
  id: uart_rs485
  tx_pin: GPIO26  # Add this; drives idle high to stabilize transceiver
  rx_pin: GPIO32
  baud_rate: 9600
  data_bits: 8
  parity: NONE
  stop_bits: 1
  rx_buffer_size: 1024

# ---- add a global to hold the latched state ----
globals:
  # ORP feeder latch (based on pH age)
  - id: orp_feed_active
    type: bool
    restore_value: no
    initial_value: 'false'

  # Timestamp of last valid pH frame (for ORP-latch logic)
  - id: last_ph_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'

  # pH feeder latch (based on tag 0x08 observations)
  - id: ph_feed_active
    type: bool
    restore_value: no
    initial_value: 'false'

  # Time of last tag 0x08==0x00 sighting
  - id: last_phfeed_ms
    type: uint32_t
    restore_value: no
    initial_value: '0'


uartex:
  uart_id: uart_rs485
  rx_timeout: 200ms
  rx_header: [0x10, 0x02]
  rx_footer: [0x10, 0x03]

# Evaluate feeder states once per second (hysteresis) and push to HA
interval:
  - interval: 1s
    then:
      - lambda: |-
          const uint32_t now = millis();

          // ORP feeder latch based on missing pH
          {
            const uint32_t age = (id(last_ph_ms) == 0) ? 0xFFFFFFFFu : (now - id(last_ph_ms));
            // ON if pH absent for >=8s; OFF when pH present within <=3s
            if (age >= 8000 && !id(orp_feed_active)) {
              id(orp_feed_active) = true;
            } else if (age <= 3000 && id(orp_feed_active)) {
              id(orp_feed_active) = false;
            }
            id(orp_feeder_bin).publish_state(id(orp_feed_active));
          }

          // pH feeder latch based on tag 0x08 (value 0x00 means "pH feeding")
          {
            const uint32_t age = (id(last_phfeed_ms) == 0) ? 0xFFFFFFFFu : (now - id(last_phfeed_ms));
            // ON if we've seen tag08==0x00 in ≤5s; OFF if quiet for ≥10s
            if (age <= 5000 && !id(ph_feed_active)) {
              id(ph_feed_active) = true;
            } else if (age >= 10000 && id(ph_feed_active)) {
              id(ph_feed_active) = false;
            }
            id(ph_feeder_bin).publish_state(id(ph_feed_active));
          }

sensor:
  - platform: uartex
    id: pool_ph
    name: "Pool pH"
    unit_of_measurement: "pH"
    accuracy_decimals: 2
    update_interval: 5s
    filters:
      - throttle_average: 30s
      - heartbeat: 60s
      - clamp: {min_value: 0.0, max_value: 14.0}
    lambda: |-
      static float last = NAN;

      // Basic length & checksum
      if (len < 6) return isnan(last) ? NAN : last;
      uint32_t sum = 0x10 + 0x02;
      for (size_t i=0; i<len-1; i++) sum += data[i];
      if ( (sum & 0x7F) != (data[len-1] & 0x7F) ) return isnan(last) ? NAN : last;

      // Expect chem frame 00 21 ...
      if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return isnan(last) ? NAN : last;

      // Find tag 0x03 (pH*10) and accept plausible 50..100 => 5.0..10.0
      for (size_t i=2; i+1 < len-1; i++) {
        if (data[i]==0x03) {
          uint8_t yy = data[i+1];
          if (yy >= 50 && yy <= 100) {
            last = yy / 10.0f;
            id(last_ph_ms) = millis();  // mark time of last good pH
            return last;
          }
        }
      }
      // No pH in this frame → keep previous value
      return isnan(last) ? NAN : last;

  - platform: uartex
    id: pool_orp
    name: "Pool ORP"
    unit_of_measurement: "mV"
    accuracy_decimals: 0
    update_interval: 5s
    filters:
      - throttle_average: 60s
      - heartbeat: 90s
      - clamp: {min_value: 0.0, max_value: 1000.0}
    lambda: |-
      static float last = NAN;

      if (len < 6) return isnan(last) ? NAN : last;
      uint32_t sum = 0x10 + 0x02;
      for (size_t i=0; i<len-1; i++) sum += data[i];
      if ( (sum & 0x7F) != (data[len-1] & 0x7F) ) return isnan(last) ? NAN : last;
      if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return isnan(last) ? NAN : last;

      // Find tag 0x02 (ORP*10) and accept plausible 20..120 => 200..1200 mV
      for (size_t i=2; i+1 < len-1; i++) {
        if (data[i]==0x02) {
          uint8_t xx = data[i+1];
          if (xx >= 20 && xx <= 120) {
            last = xx * 10.0f;
            return last;
          }
        }
      }
      return isnan(last) ? NAN : last;

  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 120s

binary_sensor:
  - platform: template
    id: orp_feeder_bin
    name: "ORP Feeder Active"
    device_class: running
    lambda: |-
      return id(orp_feed_active);

  - platform: template
    id: ph_feeder_bin
    name: "pH Feeder Active"
    device_class: running
    lambda: |-
      return id(ph_feed_active);

text_sensor:
  # Big chem frames summary (00 21 …) – includes tag 0x08
  - platform: uartex
    name: "Chem Frame Summary"
    internal: true
    update_interval: 2s
    lambda: |-
      if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return std::string();
      int orp = -1, ph10 = -1, feed18 = -1, feed08 = -1;
      for (size_t i=2; i+1 < len; i++) {
        if (data[i]==0x02) orp  = (int)data[i+1]*10;
        if (data[i]==0x03) { uint8_t y=data[i+1]; if (y>=50&&y<=100) ph10=y; }
        if (data[i]==0x18) feed18 = data[i+1];
        if (data[i]==0x08) feed08 = data[i+1];
      }
      char out[128];
      sprintf(out, "00 21 | ORP=%d pH=%s feed18=0x%02X feed08=0x%02X",
              orp,
              (ph10<0?"-":([](int v){static char b[8]; sprintf(b,"%.1f", v/10.0f); return b;})(ph10)),
              (feed18<0?0xFF:feed18),
              (feed08<0?0xFF:feed08));
      return std::string(out);

  # pH feed tag 0x08 sniffer (updates last_phfeed_ms)
  - platform: uartex
    name: "Chem pH Feed Tag08"
    internal: true
    update_interval: 1s
    lambda: |-
      if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return std::string();

      for (size_t i=2; i+1 < len; i++) {
        if (data[i] == 0x08) {
          uint8_t v = data[i+1];
          // Only treat as "pH feeding" when tag08 == 0x00
          if (v == 0x00) {
            id(last_phfeed_ms) = millis();
          }
          char buf[20];
          sprintf(buf, "tag08=0x%02X", v);
          return std::string(buf);
        }
      }
      return std::string();

  # OPTIONAL DEBUG (uncomment any of these if you need them again)
  # - platform: uartex
  #   name: "Chem Short Frames"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     if (len > 0 && len <= 4) {
  #       std::string s; s.reserve(3*len);
  #       for (size_t i=0; i<len; i++){ char b[4]; sprintf(b,"%02X ", data[i]); s += b; }
  #       return s;
  #     }
  #     return std::string();

  # - platform: uartex
  #   name: "Chem Feed Tag18"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     static int last = -1;
  #     int seen = -1;
  #     for (size_t i=0; i+1 < len; i++) {
  #       if (data[i] == 0x18) { seen = data[i+1]; break; }
  #     }
  #     if (seen < 0 || seen == last) return std::string();
  #     last = seen;
  #     char buf[16]; sprintf(buf, "tag18=0x%02X", seen);
  #     return std::string(buf);

  # - platform: uartex
  #   name: "Chem Feeder Events"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     for (size_t i=0; i + 1 < len; i++) {
  #       if (data[i] == 0x18) {
  #         uint8_t v = data[i+1];
  #         if (v == 0x01) return std::string("ORP feed ON (tag18=0x01)");
  #         if (v == 0x00) return std::string("Feed OFF (tag18=0x00)");
  #         break;
  #       }
  #     }
  #     return std::string();

  # - platform: uartex
  #   name: "Chem Tag Change Monitor"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return std::string();
  #     static uint8_t last_val[128];
  #     static bool init = false;
  #     if (!init) { for (int i=0;i<128;i++) last_val[i] = 0xFF; init = true; }
  #     for (size_t i=2; i+1 < len; i++) {
  #       uint8_t tag = data[i];
  #       uint8_t val = data[i+1];
  #       if (tag >= 0x80) continue;
  #       if (tag == 0x02 || tag == 0x03 || tag == 0x18) continue;
  #       if (last_val[tag] != val) {
  #         last_val[tag] = val;
  #         char buf[32];
  #         sprintf(buf, "chg: tag%02X=0x%02X", tag, val);
  #         return std::string(buf);
  #       }
  #     }
  #     return std::string();

  # - platform: uartex
  #   name: "Chem Tag Snapshot"
  #   internal: true
  #   update_interval: 2s
  #   lambda: |-
  #     if (!(len >= 4 && data[0]==0x00 && data[1]==0x21)) return std::string();
  #     char out[160];
  #     size_t p = 0;
  #     p += sprintf(out + p, "tags:");
  #     for (size_t i=2; i+1 < len && p < sizeof(out)-8; i++) {
  #       uint8_t tag = data[i];
  #       uint8_t val = data[i+1];
  #       if (tag >= 0x80) continue;
  #       p += sprintf(out + p, " %02X=%02X", tag, val);
  #     }
  #     return std::string();

  # - platform: template
  #   name: "Chem frames (debug)"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     float orp = id(pool_orp).state;
  #     float ph  = id(pool_ph).state;
  #     const uint32_t now = millis();
  #     const uint32_t age_ms = (id(last_ph_ms)==0) ? 0xFFFFFFFFu : (now - id(last_ph_ms));
  #     char buf[128];
  #     if (!isnan(ph))
  #       sprintf(buf, "ORP=%.0f mV (age=0s) pH=%.1f (age=%us)%s%s",
  #               orp, ph, (unsigned)(age_ms/1000),
  #               id(orp_feed_active) ? " [ORP feeding]" : "",
  #               id(ph_feed_active)  ? " [pH feeding]"  : "");
  #     else
  #       sprintf(buf, "ORP=%.0f mV (age=0s) pH=- (age=%us)%s%s",
  #               orp, (unsigned)(age_ms/1000),
  #               id(orp_feed_active) ? " [ORP feeding]" : "",
  #               id(ph_feed_active)  ? " [pH feeding]"  : "");
  #     return std::string();

  # - platform: template
  #   name: "Chem pH-miss streak"
  #   internal: true
  #   update_interval: 1s
  #   lambda: |-
  #     const uint32_t now = millis();
  #     const uint32_t age_ms = (id(last_ph_ms)==0) ? 0xFFFFFFFFu : (now - id(last_ph_ms));
  #     char buf[64];
  #     if (age_ms == 0xFFFFFFFFu) sprintf(buf, "miss=? have=0");
  #     else sprintf(buf, "age=%lus %s", (unsigned)(age_ms/1000), (age_ms>=8000?"(feeding)":""));
  #     return std::string();