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();