// https://github.com/ccutrer/balboa_worldwide_app/blob/master/doc/protocol.md // Reference:https://github.com/ccutrer/balboa_worldwide_app/wiki // ESP32 Port // M5Atom with RS485 // +12V RED // GND BLACK // A YELLOW // B WHITE // #include // #include #include #define VERSION "0.35" //#define MQTT_PREFIX "Balboa_Tube" String MQTT_PREFIX = "Balboa_Tube"; String WIFI_SSID = "xxxxx"; String WIFI_PASSWORD = "xxxx"; String BROKER = "xxxxx"; String BROKER_LOGIN = "xxxxx"; String BROKER_PASS = "xxxxxxx"; #define AUTO_TX false //if your chip needs to pull D1 high/low set this to false #define SAVE_CONN false //save the ip details above to local filesystem #define STRON String("ON").c_str() #define STROFF String("OFF").c_str() #define RLY1 23 // Relay 1 #define RLY2 33 // Relay 2 #include #include CircularBuffer Q_in; CircularBuffer Q_out; #include // MQTT client WiFiClient wifiClient; PubSubClient mqtt(wifiClient); extern uint8_t crc8(); extern void ID_request(); extern void ID_ack(); extern void rs485_send(); bool bool_led = true; uint8_t x, i, j, x_old; uint8_t last_state_crc = 0x00; uint8_t send = 0x00; uint8_t settemp = 0x00; uint8_t id = 0x00; #include unsigned long lastrx = 0; char have_config = 0; //stages: 0-> want it; 1-> requested it; 2-> got it; 3-> further processed it char have_faultlog = 0; //stages: 0-> want it; 1-> requested it; 2-> got it; 3-> further processed it char faultlog_minutes = 0; //temp logic so we only get the fault log once per 5 minutes char ip_settings = 0; //stages: 0-> want it; 1-> requested it; 2-> got it; 3-> further processed it struct { uint8_t jet1 :1; uint8_t jet2 :1; uint8_t jet3 :1; uint8_t blower :1; uint8_t light :1; uint8_t restmode:1; uint8_t highrange:1; uint8_t padding :2; uint8_t hour :5; uint8_t minutes :6; } SpaState; struct { uint8_t pump1 :2; //this could be 1=1 speed; 2=2 speeds uint8_t pump2 :2; uint8_t pump3 :2; uint8_t pump4 :2; uint8_t pump5 :2; uint8_t pump6 :2; uint8_t light1 :1; uint8_t light2 :1; uint8_t circ :1; uint8_t blower :1; uint8_t mister :1; uint8_t aux1 :1; uint8_t aux2 :1; } SpaConfig; struct { uint8_t totEntry :5; uint8_t currEntry :5; uint8_t faultCode :6; String faultMessage; uint8_t daysAgo :8; uint8_t hour :5; uint8_t minutes :6; } SpaFaultLog; void _yield() { yield(); mqtt.loop(); //httpServer.handleClient(); } void print_msg(CircularBuffer &data) { String s; //for (i = 0; i < (Q_in[1] + 2); i++) { for (i = 0; i < data.size(); i++) { x = Q_in[i]; if (x < 0x0A) s += "0"; s += String(x, HEX); s += " "; } mqtt.publish("GA_JA_02/node/msg", s.c_str()); _yield(); } void decodeFault() { SpaFaultLog.totEntry = Q_in[5]; SpaFaultLog.currEntry = Q_in[6]; SpaFaultLog.faultCode = Q_in[7]; switch (SpaFaultLog.faultCode) { // this is a inelegant way to do it, a lookup table would be better case 15: SpaFaultLog.faultMessage = "Sensors are out of sync"; break; case 16: SpaFaultLog.faultMessage = "The water flow is low"; break; case 17: SpaFaultLog.faultMessage = "The water flow has failed"; break; case 18: SpaFaultLog.faultMessage = "The settings have been reset"; break; case 19: SpaFaultLog.faultMessage = "Priming Mode"; break; case 20: SpaFaultLog.faultMessage = "The clock has failed"; break; case 21: SpaFaultLog.faultMessage = "The settings have been reset"; break; case 22: SpaFaultLog.faultMessage = "Program memory failure"; break; case 26: SpaFaultLog.faultMessage = "Sensors are out of sync -- Call for service"; break; case 27: SpaFaultLog.faultMessage = "The heater is dry"; break; case 28: SpaFaultLog.faultMessage = "The heater may be dry"; break; case 29: SpaFaultLog.faultMessage = "The water is too hot"; break; case 30: SpaFaultLog.faultMessage = "The heater is too hot"; break; case 31: SpaFaultLog.faultMessage = "Sensor A Fault"; break; case 32: SpaFaultLog.faultMessage = "Sensor B Fault"; break; case 34: SpaFaultLog.faultMessage = "A pump may be stuck on"; break; case 35: SpaFaultLog.faultMessage = "Hot fault"; break; case 36: SpaFaultLog.faultMessage = "The GFCI test failed"; break; case 37: SpaFaultLog.faultMessage = "Standby Mode (Hold Mode)"; break; default: SpaFaultLog.faultMessage = "Unknown error"; break; } SpaFaultLog.daysAgo = Q_in[8]; SpaFaultLog.hour = Q_in[9]; SpaFaultLog.minutes = Q_in[10]; mqtt.publish("GA_JA_02/fault/Entries", String(SpaFaultLog.totEntry).c_str()); mqtt.publish("GA_JA_02/fault/Entry", String(SpaFaultLog.currEntry).c_str()); mqtt.publish("GA_JA_02/fault/Code", String(SpaFaultLog.faultCode).c_str()); mqtt.publish("GA_JA_02/fault/Message", SpaFaultLog.faultMessage.c_str()); mqtt.publish("GA_JA_02/fault/DaysAgo", String(SpaFaultLog.daysAgo).c_str()); mqtt.publish("GA_JA_02/fault/Hours", String(SpaFaultLog.hour).c_str()); mqtt.publish("GA_JA_02/fault/Minutes", String(SpaFaultLog.minutes).c_str()); have_faultlog = 2; } void decodeSettings() { //mqtt.publish("GA_JA_02/config/status", "Got config"); SpaConfig.pump1 = Q_in[5] & 0x03; SpaConfig.pump2 = (Q_in[5] & 0x0C) >> 2; SpaConfig.pump3 = (Q_in[5] & 0x30) >> 4; SpaConfig.pump4 = (Q_in[5] & 0xC0) >> 6; SpaConfig.pump5 = (Q_in[6] & 0x03); SpaConfig.pump6 = (Q_in[6] & 0xC0) >> 6; SpaConfig.light1 = (Q_in[7] & 0x03); SpaConfig.light2 = (Q_in[7] >> 2) & 0x03; SpaConfig.circ = ((Q_in[8] & 0x80) != 0); SpaConfig.blower = ((Q_in[8] & 0x03) != 0); SpaConfig.mister = ((Q_in[9] & 0x30) != 0); SpaConfig.aux1 = ((Q_in[9] & 0x01) != 0); SpaConfig.aux2 = ((Q_in[9] & 0x02) != 0); mqtt.publish("GA_JA_02/config/pumps1", String(SpaConfig.pump1).c_str()); mqtt.publish("GA_JA_02/config/pumps2", String(SpaConfig.pump2).c_str()); mqtt.publish("GA_JA_02/config/pumps3", String(SpaConfig.pump3).c_str()); mqtt.publish("GA_JA_02/config/pumps4", String(SpaConfig.pump4).c_str()); mqtt.publish("GA_JA_02/config/pumps5", String(SpaConfig.pump5).c_str()); mqtt.publish("GA_JA_02/config/pumps6", String(SpaConfig.pump6).c_str()); mqtt.publish("GA_JA_02/config/light1", String(SpaConfig.light1).c_str()); mqtt.publish("GA_JA_02/config/light2", String(SpaConfig.light2).c_str()); mqtt.publish("GA_JA_02/config/circ", String(SpaConfig.circ).c_str()); mqtt.publish("GA_JA_02/config/blower", String(SpaConfig.blower).c_str()); mqtt.publish("GA_JA_02/config/mister", String(SpaConfig.mister).c_str()); mqtt.publish("GA_JA_02/config/aux1", String(SpaConfig.aux1).c_str()); mqtt.publish("GA_JA_02/config/aux2", String(SpaConfig.aux2).c_str()); have_config = 2; } void decodeState() { String s; double d = 0.0; double c = 0.0; // DEBUG for finding meaning: //print_msg(Q_in); // 25:Flag Byte 20 - Set Temperature d = Q_in[25] / 2; if (Q_in[25] % 2 == 1) d += 0.5; mqtt.publish("GA_JA_02/target_temp/state", String(d, 2).c_str()); // 7:Flag Byte 2 - Actual temperature if (Q_in[7] != 0xFF) { d = Q_in[7] / 2; if (Q_in[7] % 2 == 1) d += 0.5; if (c > 0) { if ((d > c * 1.2) || (d < c * 0.8)) d = c; //remove spurious readings greater or less than 20% away from previous read } mqtt.publish("GA_JA_02/temperature/state", String(d, 2).c_str()); c = d; } else { d = 0; } // REMARK Move upper publish to HERE to get 0 for unknown temperature // 8:Flag Byte 3 Hour & 9:Flag Byte 4 Minute => Time if (Q_in[8] < 10) s = "0"; else s = ""; SpaState.hour = Q_in[8]; s = String(Q_in[8]) + ":"; if (Q_in[9] < 10) s += "0"; s += String(Q_in[9]); SpaState.minutes = Q_in[9]; mqtt.publish("GA_JA_02/time/state", s.c_str()); // 10:Flag Byte 5 - Heating Mode switch (Q_in[10]) { case 0:mqtt.publish("GA_JA_02/heatingmode/state", STRON); //Ready mqtt.publish("GA_JA_02/heat_mode/state", "heat"); //Ready SpaState.restmode = 0; break; case 3:// Ready-in-Rest SpaState.restmode = 0; break; case 1:mqtt.publish("GA_JA_02/heatingmode/state", STROFF); //Rest mqtt.publish("GA_JA_02/heat_mode/state", "off"); //Rest SpaState.restmode = 1; break; } // 15:Flags Byte 10 / Heat status, Temp Range d = bitRead(Q_in[15], 4); if (d == 0) mqtt.publish("GA_JA_02/heatstate/state", STROFF); else if (d == 1 || d == 2) mqtt.publish("GA_JA_02/heatstate/state", STRON); d = bitRead(Q_in[15], 2); if (d == 0) { mqtt.publish("GA_JA_02/highrange/state", STROFF); //LOW SpaState.highrange = 0; } else if (d == 1) { mqtt.publish("GA_JA_02/highrange/state", STRON); //HIGH SpaState.highrange = 1; } // 16:Flags Byte 11 if (bitRead(Q_in[16], 1) == 1) { mqtt.publish("GA_JA_02/jet_1/state", STRON); SpaState.jet1 = 1; } else { mqtt.publish("GA_JA_02/jet_1/state", STROFF); SpaState.jet1 = 0; } if (bitRead(Q_in[16], 3) == 1) { mqtt.publish("GA_JA_02/jet_2/state", STRON); SpaState.jet2 = 1; } else { mqtt.publish("GA_JA_02/jet_2/state", STROFF); SpaState.jet2 = 0; } if (bitRead(Q_in[16], 5) == 1) { mqtt.publish("GA_JA_02/jet_3/state", STRON); SpaState.jet3 = 1; } else { mqtt.publish("GA_JA_02/jet_3/state", STROFF); SpaState.jet3 = 0; } // 18:Flags Byte 13 if (bitRead(Q_in[18], 1) == 1) mqtt.publish("GA_JA_02/circ/state", STRON); else mqtt.publish("GA_JA_02/circ/state", STROFF); if (bitRead(Q_in[18], 2) == 1) { mqtt.publish("GA_JA_02/blower/state", STRON); SpaState.blower = 1; } else { mqtt.publish("GA_JA_02/blower/state", STROFF); SpaState.blower = 0; } // 19:Flags Byte 14 if (Q_in[19] == 0x03) { mqtt.publish("GA_JA_02/light/state", STRON); SpaState.light = 1; } else { mqtt.publish("GA_JA_02/light/state", STROFF); SpaState.light = 0; } last_state_crc = Q_in[Q_in[1]]; // Publish own relay states s = "OFF"; if (digitalRead(RLY1) == LOW) s = "ON"; mqtt.publish("GA_JA_02/relay_1/state", s.c_str()); s = "OFF"; if (digitalRead(RLY2) == LOW) s = "ON"; mqtt.publish("GA_JA_02/relay_2/state", s.c_str()); } /////////////////////////////////////////////////////////////////////////////// void hardreset() { ESP.restart(); //ESP.wdtDisable(); //while (1) {}; } void mqttpubsub() { // ONLY DO THE FOLLOWING IF have_config == true otherwise it will not work //clear topics: mqtt.publish("/Spa", ""); String Payload; mqtt.publish("GA_JA_02/node/state", "ON"); mqtt.publish("GA_JA_02/node/debug", "Config received"); //mqtt.publish("GA_JA_02/node/debug", String(millis()).c_str()); //mqtt.publish("GA_JA_02/node/debug", String(oldstate).c_str()); mqtt.publish("GA_JA_02/node/version", VERSION); // mqtt.publish("GA_JA_02/node/flashsize", String(ESP.getFlashChipRealSize()).c_str()); // mqtt.publish("GA_JA_02/node/chipid", String(ESP.getChipId()).c_str()); mqtt.publish("GA_JA_02/node/speed", String(ESP.getCpuFreqMHz()).c_str()); // ... and resubscribe mqtt.subscribe("GA_JA_02/command"); mqtt.subscribe("GA_JA_02/target_temp/set"); mqtt.subscribe("GA_JA_02/heatingmode/set"); mqtt.subscribe("GA_JA_02/heat_mode/set"); mqtt.subscribe("GA_JA_02/highrange/set"); //OPTIONAL ELEMENTS if (SpaConfig.pump1 != 0) { mqtt.subscribe("GA_JA_02/jet_1/set"); } if (SpaConfig.pump2 != 0) { mqtt.subscribe("GA_JA_02/jet_2/set"); } if (SpaConfig.pump3 != 0) { mqtt.subscribe("GA_JA_02/jet_3/set"); } if (SpaConfig.blower) { mqtt.subscribe("GA_JA_02/blower/set"); } if (SpaConfig.light1) { mqtt.subscribe("GA_JA_02/light/set"); } mqtt.subscribe("GA_JA_02/relay_1/set"); mqtt.subscribe("GA_JA_02/relay_2/set"); //not sure what this is last_state_crc = 0x00; //done with config have_config = 3; } void reconnect() { //int oldstate = mqtt.state(); //boolean connection = false; // Loop until we're reconnected if (!mqtt.connected()) { // Attempt to connect if (BROKER_PASS == "") { //connection = mqtt.connect(String(String("Spa") + String(millis())).c_str()); } else { //connection = mqtt.connect("Spa1", BROKER_LOGIN.c_str(), BROKER_PASS.c_str()); } if (have_config == 3) { have_config = 2; // we have disconnected, let's republish our configuration } } mqtt.setBufferSize(512); //increase pubsubclient buffer size } // function called when a MQTT message arrived void callback(char* p_topic, byte * p_payload, unsigned int p_length) { // concat the payload into a string String payload; for (uint8_t i = 0; i < p_length; i++) { payload.concat((char)p_payload[i]); } String topic = String(p_topic); mqtt.publish("GA_JA_02/node/debug", topic.c_str()); _yield(); // handle message topic if (topic.startsWith("GA_JA_02/relay_")) { bool newstate = 0; if (payload.equals("ON")) newstate = LOW; else if (payload.equals("OFF")) newstate = HIGH; if (topic.charAt(10) == '1') { pinMode(RLY1, INPUT); delay(25); pinMode(RLY1, OUTPUT); digitalWrite(RLY1, newstate); } else if (topic.charAt(10) == '2') { pinMode(RLY2, INPUT); delay(25); pinMode(RLY2, OUTPUT); digitalWrite(RLY2, newstate); } } else if (topic.equals("GA_JA_02/command")) { if (payload.equals("reset")) hardreset(); } else if (topic.equals("GA_JA_02/heatingmode/set")) { if (payload.equals("ON") && SpaState.restmode == 1) send = 0x51; // ON = Ready; OFF = Rest else if (payload.equals("OFF") && SpaState.restmode == 0) send = 0x51; } else if (topic.equals("GA_JA_02/heat_mode/set")) { if (payload.equals("heat") && SpaState.restmode == 1) send = 0x51; // ON = Ready; OFF = Rest else if (payload.equals("off") && SpaState.restmode == 0) send = 0x51; } else if (topic.equals("GA_JA_02/light/set")) { if (payload.equals("ON") && SpaState.light == 0) send = 0x11; else if (payload.equals("OFF") && SpaState.light == 1) send = 0x11; } else if (topic.equals("GA_JA_02/jet_1/set")) { if (payload.equals("ON") && SpaState.jet1 == 0) send = 0x04; else if (payload.equals("OFF") && SpaState.jet1 == 1) send = 0x04; } else if (topic.equals("GA_JA_02/jet_2/set")) { if (payload.equals("ON") && SpaState.jet2 == 0) send = 0x05; else if (payload.equals("OFF") && SpaState.jet2 == 1) send = 0x05; } else if (topic.equals("GA_JA_02/jet_3/set")) { if (payload.equals("ON") && SpaState.jet3 == 0) send = 0x06; else if (payload.equals("OFF") && SpaState.jet3 == 1) send = 0x06; } else if (topic.equals("GA_JA_02/blower/set")) { if (payload.equals("ON") && SpaState.blower == 0) send = 0x0C; else if (payload.equals("OFF") && SpaState.blower == 1) send = 0x0C; } else if (topic.equals("GA_JA_02/highrange/set")) { if (payload.equals("ON") && SpaState.highrange == 0) send = 0x50; //ON = High, OFF = Low else if (payload.equals("OFF") && SpaState.highrange == 1) send = 0x50; } else if (topic.equals("GA_JA_02/target_temp/set")) { // Get new set temperature double d = payload.toDouble(); if (d > 0) d *= 2; // Convert to internal representation settemp = d; send = 0xff; } } /////////////////////////////////////////////////////////////////////////////// void setup() { M5.begin(true,false,true); M5.dis.drawpix(0, 0x0000ff); Serial.begin(115200); String error_msg = ""; Serial.println("Setup"); pinMode(RLY1, OUTPUT); digitalWrite(RLY1, HIGH); pinMode(RLY2, OUTPUT); digitalWrite(RLY2, HIGH); // Spa communication, 115.200 baud 8N Serial2.begin(115200,SERIAL_8N1,22,19); Serial.println("RS485 started"); // give Spa time to wake up after POST for (uint8_t i = 0; i < 5; i++) { delay(1000); yield(); } Q_in.clear(); Q_out.clear(); // WiFi.setOutputPower(20.5); // this sets wifi to highest power WiFi.begin(WIFI_SSID.c_str(), WIFI_PASSWORD.c_str()); unsigned long timeout = millis() + 10000; while (WiFi.status() != WL_CONNECTED && millis() < timeout) { Serial.println("Connecting WiFi"); yield(); } // Reset because of no connection if (WiFi.status() != WL_CONNECTED) { hardreset(); } mqtt.setServer(BROKER.c_str(), 1883); mqtt.setCallback(callback); mqtt.setKeepAlive(10); mqtt.setSocketTimeout(20); /*the below is for debug purposes mqtt.connect("Spa1", BROKER_LOGIN.c_str(), BROKER_PASS.c_str()); mqtt.publish("GA_JA_02/debug/wifi_ssid", WIFI_SSID.c_str()); mqtt.publish("GA_JA_02/debug/wifi_password", WIFI_PASSWORD.c_str()); mqtt.publish("GA_JA_02/debug/broker", BROKER.c_str()); mqtt.publish("GA_JA_02/debug/broker_login", BROKER_LOGIN.c_str()); mqtt.publish("GA_JA_02/debug/broker_pass", BROKER_PASS.c_str()); mqtt.publish("GA_JA_02/debug/error", error_msg.c_str()); */ } void loop() { if (WiFi.status() != WL_CONNECTED) ESP.restart(); if (!mqtt.connected()) reconnect(); // Serial.println("WIFI OK"); // mqttpubsub(); if (have_config == 2) mqttpubsub(); //do mqtt stuff after we're connected and if we have got the config elements //httpServer.handleClient(); needed? _yield(); // Read from Spa RS485 if (Serial2.available()) { // Serial.println("READING :::::::::"); x = Serial2.read(); Q_in.push(x); // Drop until SOF is seen if (Q_in.first() != 0x7E) Q_in.clear(); lastrx = millis(); } //Every x minutes, read the fault log using SpaState,minutes if (SpaState.minutes % 5 == 0) { //logic to only get the error message once -> this is dirty //have_faultlog = 0; if (have_faultlog == 2) { // we got the fault log before and treated it if (faultlog_minutes == SpaState.minutes) { // we got the fault log this interval so do nothing } else { faultlog_minutes = SpaState.minutes; have_faultlog = 0; } } } // DEBUG if (x != x_old) { // mqtt.publish("GA_JA_02/rcv", String(x).c_str()); _yield(); x_old = x; if (bool_led) { M5.dis.drawpix(0, 0x00ff00); bool_led = !bool_led; } else { M5.dis.drawpix(0, 0xff0000); bool_led = !bool_led; } } // Double SOF-marker, drop last one if (Q_in[1] == 0x7E && Q_in.size() > 1) Q_in.pop(); // Complete package //if (x == 0x7E && Q_in[0] == 0x7E && Q_in[1] != 0x7E) { if (x == 0x7E && Q_in.size() > 2) { //print_msg(); // Unregistered or yet in progress if (id == 0) { if (Q_in[2] == 0xFE) print_msg(Q_in); // FE BF 02:got new client ID if (Q_in[2] == 0xFE && Q_in[4] == 0x02) { id = Q_in[5]; if (id > 0x2F) id = 0x2F; ID_ack(); mqtt.publish("GA_JA_02/node/id", String(id).c_str()); } // FE BF 00:Any new clients? if (Q_in[2] == 0xFE && Q_in[4] == 0x00) { ID_request(); } } else if (Q_in[2] == id && Q_in[4] == 0x06) { // we have an ID, do clever stuff // id BF 06:Ready to Send if (send == 0xff) { // 0xff marks dirty temperature for now Q_out.push(id); Q_out.push(0xBF); Q_out.push(0x20); Q_out.push(settemp); } else if (send == 0x00) { if (have_config == 0) { // Get configuration of the hot tub Q_out.push(id); Q_out.push(0xBF); Q_out.push(0x22); Q_out.push(0x00); Q_out.push(0x00); Q_out.push(0x01); //mqtt.publish("GA_JA_02/config/status", "Getting config"); have_config = 1; } else if (have_faultlog == 0) { // Get the fault log Q_out.push(id); Q_out.push(0xBF); Q_out.push(0x22); Q_out.push(0x20); Q_out.push(0xFF); Q_out.push(0x00); have_faultlog = 1; } else { // A Nothing to Send message is sent by a client immediately after a Clear to Send message if the client has no messages to send. Q_out.push(id); Q_out.push(0xBF); Q_out.push(0x07); } } else { // Send toggle commands Q_out.push(id); Q_out.push(0xBF); Q_out.push(0x11); Q_out.push(send); Q_out.push(0x00); } rs485_send(); send = 0x00; } else if (Q_in[2] == id && Q_in[4] == 0x2E) { if (last_state_crc != Q_in[Q_in[1]]) { decodeSettings(); } } else if (Q_in[2] == id && Q_in[4] == 0x28) { if (last_state_crc != Q_in[Q_in[1]]) { decodeFault(); } } else if (Q_in[2] == 0xFF && Q_in[4] == 0x13) { // FF AF 13:Status Update - Packet index offset 5 if (last_state_crc != Q_in[Q_in[1]]) { decodeState(); } } else { // DEBUG for finding meaning //if (Q_in[2] & 0xFE || Q_in[2] == id) //print_msg(Q_in); } // Clean up queue _yield(); Q_in.clear(); } // Long time no receive if (millis() - lastrx > 10000) { hardreset(); } }