diff --git a/tasmota/xsns_68_opentherm.ino b/tasmota/xsns_68_opentherm.ino new file mode 100644 index 000000000..bbdc90f96 --- /dev/null +++ b/tasmota/xsns_68_opentherm.ino @@ -0,0 +1,598 @@ +/* + xsns_68_opentherm.ino - OpenTherm protocol support for Tasmota + + Copyright (C) 2020 Yuriy Sannikov + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include + +#ifdef USE_OPENTHERM + +#define XSNS_68 68 + +// Hot water and boiler parameter ranges +#define OT_HOT_WATER_MIN 23 +#define OT_HOT_WATER_MAX 55 +#define OT_BOILER_MIN 40 +#define OT_BOILER_MAX 85 + +#define OT_HOT_WATER_DEFAULT 36; +#define OT_BOILER_DEFAULT 85; + +// Seconds before OT will make an attempt to connect to the boiler after connection error +#define SNS_OT_DISCONNECT_COOLDOWN_SECONDS 10 + +// Count of the OpenThermSettingsFlags +#define OT_FLAGS_COUNT 6 +enum OpenThermSettingsFlags +{ + // If set, central heating on/off state follows diagnostic indication bit(6), however + // EnableCentralHeating flag has a priority over it + EnableCentralHeatingOnDiagnostics = 0x01, + // If set, DHW is on after restart. + EnableHotWater = 0x02, + // If set, keep CH always on after restart. If off, follows the EnableCentralHeatingOnDiagnostics rule + EnableCentralHeating = 0x04, + EnableCooling = 0x08, + EnableTemperatureCompensation = 0x10, + EnableCentralHeating2 = 0x20, +}; + +enum OpenThermConnectionStatus +{ + OTC_NONE, // OT not initialized + OTC_DISCONNECTED, // OT communication timed out + OTC_CONNECTING, // Connecting after start or from DISCONNECTED state + OTC_HANDSHAKE, // Wait for the handshake response + OTC_READY, // Last Known Good response state is SUCCESS and no requests are in flight + OTC_INFLIGHT // Request sent, waiting from the response +}; + +OpenThermConnectionStatus sns_ot_connection_status = OpenThermConnectionStatus::OTC_NONE; +uint8_t sns_ot_disconnect_cooldown = 0; + +OpenTherm *sns_ot_master = NULL; + +// Has valid values if connection status is READY or INFLIGHT +typedef struct OT_BOILER_STATUS_T +{ + // Boiler fault code + uint8_t m_fault_code; + // Boiler OEM fault code + uint8_t m_oem_fault_code; + // Boilder OEM Diagnostics code + uint16_t m_oem_diag_code; + // OpenTherm ID(3) response. + uint8_t m_slave_flags; + // OpenTherm ID(1) codes. Should be used to display state + unsigned long m_slave_raw_status; + // Desired boiler states + bool m_enableCentralHeating; + bool m_enableHotWater; + bool m_enableCooling; + bool m_enableOutsideTemperatureCompensation; + bool m_enableCentralHeating2; + + // Some boilers has an input for the heat request. When short, heat is requested + // OT ID(0) bit 6 may indicate state of the Heat Request input + // By enabling this bit we will set m_enableCentralHeating to true when OT ID(0) bit 6 is set. + // This enables to use external mechanical thermostat to enable heating. + // Some of the use cases might be setting an emergency temperature to prevent freezing + // in case of the software thermostat failure. + bool m_useDiagnosticIndicationAsHeatRequest; + + // Hot Water temperature + float m_hotWaterSetpoint_read; + // Flame Modulation + float m_flame_modulation_read; + // Boiler Temperature + float m_boiler_temperature_read; + + // Boiler desired values + float m_boilerSetpoint; + float m_hotWaterSetpoint; + +} OT_BOILER_STATUS; + +OT_BOILER_STATUS sns_ot_boiler_status; + +const char *sns_opentherm_connection_stat_to_str(int status) +{ + switch (status) + { + case OpenThermConnectionStatus::OTC_NONE: + return "NONE"; + case OpenThermConnectionStatus::OTC_DISCONNECTED: + return "FAULT"; + case OpenThermConnectionStatus::OTC_CONNECTING: + return "CONNECTING"; + case OpenThermConnectionStatus::OTC_HANDSHAKE: + return "HANDSHAKE"; + case OpenThermConnectionStatus::OTC_READY: + return "READY"; + case OpenThermConnectionStatus::OTC_INFLIGHT: + return "BUSY"; + default: + return "UNKNOWN"; + } +} + +void sns_opentherm_init_boiler_status() +{ + memset(&sns_ot_boiler_status, 0, sizeof(OT_BOILER_STATUS)); + + // Settings + sns_ot_boiler_status.m_useDiagnosticIndicationAsHeatRequest = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableCentralHeatingOnDiagnostics; + sns_ot_boiler_status.m_enableHotWater = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableHotWater; + sns_ot_boiler_status.m_enableCentralHeating = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableCentralHeating; + sns_ot_boiler_status.m_enableCooling = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableCooling; + sns_ot_boiler_status.m_enableOutsideTemperatureCompensation = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableTemperatureCompensation; + sns_ot_boiler_status.m_enableCentralHeating2 = Settings.ot_flags & (uint8_t)OpenThermSettingsFlags::EnableCentralHeating2; + + sns_ot_boiler_status.m_boilerSetpoint = (float)Settings.ot_boiler_setpoint; + sns_ot_boiler_status.m_hotWaterSetpoint = (float)Settings.ot_hot_water_setpoint; + + sns_ot_boiler_status.m_fault_code = 0; + sns_ot_boiler_status.m_oem_fault_code = 0; + sns_ot_boiler_status.m_oem_diag_code = 0; + sns_ot_boiler_status.m_hotWaterSetpoint_read = 0; + sns_ot_boiler_status.m_flame_modulation_read = 0; + sns_ot_boiler_status.m_boiler_temperature_read = 0; +} + +void ICACHE_RAM_ATTR sns_opentherm_handleInterrupt() +{ + sns_ot_master->handleInterrupt(); +} + +void sns_opentherm_processResponseCallback(unsigned long response, int st) +{ + OpenThermResponseStatus status = (OpenThermResponseStatus)st; + AddLog_P2(LOG_LEVEL_DEBUG_MORE, + PSTR("[OTH]: Processing response. Status=%s, Response=0x%lX"), + sns_ot_master->statusToString(status), response); + + if (sns_ot_connection_status == OpenThermConnectionStatus::OTC_HANDSHAKE) + { + return sns_ot_process_handshake(response, st); + } + + switch (status) + { + case OpenThermResponseStatus::SUCCESS: + if (sns_ot_master->isValidResponse(response)) + { + sns_opentherm_process_success_response(&sns_ot_boiler_status, response); + } + sns_ot_connection_status = OpenThermConnectionStatus::OTC_READY; + break; + + case OpenThermResponseStatus::INVALID: + sns_opentherm_check_retry_request(); + sns_ot_connection_status = OpenThermConnectionStatus::OTC_READY; + break; + + // Timeout may indicate not valid/supported command or connection error + // In this case we do reconnect. + // If this command will timeout multiple times, it will be excluded from the rotation later on + // after couple of failed attempts. See sns_opentherm_check_retry_request logic + case OpenThermResponseStatus::TIMEOUT: + sns_opentherm_check_retry_request(); + sns_ot_connection_status = OpenThermConnectionStatus::OTC_DISCONNECTED; + break; + } +} + +bool sns_opentherm_Init() +{ + if (pin[GPIO_BOILER_OT_RX] < 99 && pin[GPIO_BOILER_OT_TX] < 99) + { + sns_ot_master = new OpenTherm(pin[GPIO_BOILER_OT_RX], pin[GPIO_BOILER_OT_TX]); + sns_ot_master->begin(sns_opentherm_handleInterrupt, sns_opentherm_processResponseCallback); + sns_ot_connection_status = OpenThermConnectionStatus::OTC_CONNECTING; + + sns_opentherm_init_boiler_status(); + return true; + } + return false; + // !warning, sns_opentherm settings are not ready at this point +} + +void sns_opentherm_stat(bool json) +{ + if (!sns_ot_master) + { + return; + } + const char *statusStr = sns_opentherm_connection_stat_to_str(sns_ot_connection_status); + + if (json) + { + ResponseAppend_P(PSTR(",\"OPENTHERM\":{")); + ResponseAppend_P(PSTR("\"conn\":\"%s\","), statusStr); + ResponseAppend_P(PSTR("\"settings\":%d,"), Settings.ot_flags); + sns_opentherm_dump_telemetry(); + ResponseJsonEnd(); +#ifdef USE_WEBSERVER + } + else + { + WSContentSend_P(PSTR("{s}OpenTherm status{m}%s (0x%X){e}"), statusStr, (int)sns_ot_boiler_status.m_slave_flags); + if (sns_ot_connection_status < OpenThermConnectionStatus::OTC_READY) + { + return; + } + WSContentSend_P(PSTR("{s}Std/OEM Fault Codes{m}%d / %d{e}"), + (int)sns_ot_boiler_status.m_fault_code, + (int)sns_ot_boiler_status.m_oem_fault_code); + + WSContentSend_P(PSTR("{s}OEM Diagnostic Code{m}%d{e}"), + (int)sns_ot_boiler_status.m_oem_diag_code); + + WSContentSend_P(PSTR("{s}Hot Water Setpoint{m}%d{e}"), + (int)sns_ot_boiler_status.m_hotWaterSetpoint_read); + + WSContentSend_P(PSTR("{s}Flame Modulation{m}%d{e}"), + (int)sns_ot_boiler_status.m_flame_modulation_read); + + WSContentSend_P(PSTR("{s}Boiler Temp/Setpnt{m}%d / %d{e}"), + (int)sns_ot_boiler_status.m_boiler_temperature_read, + (int)sns_ot_boiler_status.m_boilerSetpoint); + + if (OpenTherm::isCentralHeatingActive(sns_ot_boiler_status.m_slave_raw_status)) + { + WSContentSend_P(PSTR("{s}Central Heating is ACTIVE{m}{e}")); + } + + if (sns_ot_boiler_status.m_enableHotWater) + { + WSContentSend_P(PSTR("{s}Hot Water is Enabled{m}{e}")); + } + + if (OpenTherm::isHotWaterActive(sns_ot_boiler_status.m_slave_raw_status)) + { + WSContentSend_P(PSTR("{s}Hot Water is ACTIVE{m}{e}")); + } + + if (OpenTherm::isFlameOn(sns_ot_boiler_status.m_slave_raw_status)) + { + WSContentSend_P(PSTR("{s}Flame is ACTIVE{m}{e}")); + } + + if (sns_ot_boiler_status.m_enableCooling) + { + WSContentSend_P(PSTR("{s}Cooling is Enabled{m}{e}")); + } + + if (OpenTherm::isCoolingActive(sns_ot_boiler_status.m_slave_raw_status)) + { + WSContentSend_P(PSTR("{s}Cooling is ACTIVE{m}{e}")); + } + + if (OpenTherm::isDiagnostic(sns_ot_boiler_status.m_slave_raw_status)) + { + WSContentSend_P(PSTR("{s}Diagnostic Indication{m}{e}")); + } + +#endif // USE_WEBSERVER + } +} + +void sns_ot_start_handshake() +{ + if (!sns_ot_master) + { + return; + } + + AddLog_P2(LOG_LEVEL_DEBUG, PSTR("[OTH]: perform handshake")); + + sns_ot_master->sendRequestAync( + OpenTherm::buildRequest(OpenThermMessageType::READ_DATA, OpenThermMessageID::SConfigSMemberIDcode, 0)); + + sns_ot_connection_status = OpenThermConnectionStatus::OTC_HANDSHAKE; +} + +void sns_ot_process_handshake(unsigned long response, int st) +{ + OpenThermResponseStatus status = (OpenThermResponseStatus)st; + + if (status != OpenThermResponseStatus::SUCCESS || !sns_ot_master->isValidResponse(response)) + { + AddLog_P2(LOG_LEVEL_ERROR, + PSTR("[OTH]: getSlaveConfiguration failed. Status=%s"), + sns_ot_master->statusToString(status)); + sns_ot_connection_status = OpenThermConnectionStatus::OTC_DISCONNECTED; + return; + } + + AddLog_P2(LOG_LEVEL_DEBUG, PSTR("[OTH]: getLastResponseStatus SUCCESS. Slave Cfg: %lX"), response); + + sns_ot_boiler_status.m_slave_flags = (response & 0xFF00) >> 8; + + sns_ot_connection_status = OpenThermConnectionStatus::OTC_READY; +} + +void sns_opentherm_CheckSettings(void) +{ + bool settingsValid = true; + + settingsValid &= Settings.ot_hot_water_setpoint >= OT_HOT_WATER_MIN; + settingsValid &= Settings.ot_hot_water_setpoint <= OT_HOT_WATER_MAX; + settingsValid &= Settings.ot_boiler_setpoint >= OT_BOILER_MIN; + settingsValid &= Settings.ot_boiler_setpoint <= OT_BOILER_MAX; + + if (!settingsValid) + { + Settings.ot_hot_water_setpoint = OT_HOT_WATER_DEFAULT; + Settings.ot_boiler_setpoint = OT_BOILER_DEFAULT; + Settings.ot_flags = + OpenThermSettingsFlags::EnableCentralHeatingOnDiagnostics | + OpenThermSettingsFlags::EnableHotWater; + } +} +/*********************************************************************************************\ + * Command Processing +\*********************************************************************************************/ +const char *sns_opentherm_flag_text(uint8_t mode) +{ + switch ((OpenThermSettingsFlags)mode) + { + case OpenThermSettingsFlags::EnableCentralHeatingOnDiagnostics: + return "CHOD"; + case OpenThermSettingsFlags::EnableHotWater: + return "DHW"; + case OpenThermSettingsFlags::EnableCentralHeating: + return "CH"; + case OpenThermSettingsFlags::EnableCooling: + return "COOL"; + case OpenThermSettingsFlags::EnableTemperatureCompensation: + return "OTC"; + case OpenThermSettingsFlags::EnableCentralHeating2: + return "CH2"; + default: + return "?"; + } +} + +uint8_t sns_opentherm_parse_flag(char *flag) +{ + if (!strncmp(flag, "CHOD", 4)) + { + return OpenThermSettingsFlags::EnableCentralHeatingOnDiagnostics; + } + else if (!strncmp(flag, "COOL", 4)) + { + return OpenThermSettingsFlags::EnableCooling; + } + else if (!strncmp(flag, "DHW", 3)) + { + return OpenThermSettingsFlags::EnableHotWater; + } + else if (!strncmp(flag, "OTC", 3)) + { + return OpenThermSettingsFlags::EnableTemperatureCompensation; + } + else if (!strncmp(flag, "CH2", 3)) + { + return OpenThermSettingsFlags::EnableCentralHeating2; + } + else if (!strncmp(flag, "CH", 2)) + { + return OpenThermSettingsFlags::EnableCentralHeating; + } + return 0; +} + +uint8_t sns_opentherm_read_flags(char *data, uint32_t len) +{ + uint8_t tokens = 1; + for (int i = 0; i < len; ++i) + { + if (data[i] == ',') + { + ++tokens; + } + } + uint8_t result = 0; + char sub_string[XdrvMailbox.data_len + 1]; + for (int i = 1; i <= tokens; ++i) + { + char *flag = subStr(sub_string, data, ",", i); + if (!flag) + { + break; + } + result |= sns_opentherm_parse_flag(flag); + } + return result; +} +#define D_PRFX_OTHERM "ot_" +// set the boiler temperature (CH). Sutable for the PID app. +// After restart will use the defaults from the settings +#define D_CMND_OTHERM_BOILER_SETPOINT "tboiler" +// set hot water (DHW) temperature. Do not write it in the flash memory. +// suitable for the temporary changes +#define D_CMND_OTHERM_DHW_SETPOINT "twater" +// This command will save CH and DHW setpoints into the settings. Those values will be used after system restart +// The reason to separate set and save is to reduce flash memory write count, especially if boiler temperature is controlled +// by the PID thermostat +#define D_CMND_OTHERM_SAVE_SETTINGS "save_setpoints" +// Get or set flags + +// EnableCentralHeatingOnDiagnostics -> CHOD +// EnableHotWater -> DHW +// EnableCentralHeating -> CH +// EnableCooling -> COOL +// EnableTemperatureCompensation -> OTC +// EnableCentralHeating2 -> CH2 +#define D_CMND_OTHERM_FLAGS "flags" + +// Get/Set boiler status m_enableCentralHeating value. It's equivalent of the EnableCentralHeating settings +// flag value, however, this command does not update the settings. +// Usefull to buld automations +// Please note, if you set it to "0" and EnableCentralHeatingOnDiagnostics is set +// boiler will follow the Diagnostics bit and won't turn CH off. When Diagnostics bit cleared, +// and "ot_ch" is "1", boiler will keep heating +#define D_CMND_SET_CENTRAL_HEATING_ENABLED "ch" + +const char kOpenThermCommands[] PROGMEM = D_PRFX_OTHERM "|" D_CMND_OTHERM_BOILER_SETPOINT "|" D_CMND_OTHERM_DHW_SETPOINT + "|" D_CMND_OTHERM_SAVE_SETTINGS "|" D_CMND_OTHERM_FLAGS "|" D_CMND_SET_CENTRAL_HEATING_ENABLED; + +void (*const OpenThermCommands[])(void) PROGMEM = { + &sns_opentherm_boiler_setpoint_cmd, + &sns_opentherm_hot_water_setpoint_cmd, + &sns_opentherm_save_settings_cmd, + &sns_opentherm_flags_cmd, + &sns_opentherm_set_central_heating_cmd}; + +void sns_opentherm_cmd(void) { } +void sns_opentherm_boiler_setpoint_cmd(void) +{ + bool query = strlen(XdrvMailbox.data) == 0; + if (!query) + { + sns_ot_boiler_status.m_boilerSetpoint = atof(XdrvMailbox.data); + } + ResponseCmndFloat(sns_ot_boiler_status.m_boilerSetpoint, Settings.flag2.temperature_resolution); +} + +void sns_opentherm_hot_water_setpoint_cmd(void) +{ + bool query = strlen(XdrvMailbox.data) == 0; + if (!query) + { + sns_ot_boiler_status.m_hotWaterSetpoint = atof(XdrvMailbox.data); + } + ResponseCmndFloat(sns_ot_boiler_status.m_hotWaterSetpoint, Settings.flag2.temperature_resolution); +} + +void sns_opentherm_save_settings_cmd(void) +{ + Settings.ot_hot_water_setpoint = (uint8_t)sns_ot_boiler_status.m_hotWaterSetpoint; + Settings.ot_boiler_setpoint = (uint8_t)sns_ot_boiler_status.m_boilerSetpoint; + ResponseCmndDone(); +} + +void sns_opentherm_flags_cmd(void) +{ + bool query = strlen(XdrvMailbox.data) == 0; + if (!query) + { + // Set flags value + Settings.ot_flags = sns_opentherm_read_flags(XdrvMailbox.data, XdrvMailbox.data_len); + // Reset boiler status to apply settings + sns_opentherm_init_boiler_status(); + } + bool addComma = false; + mqtt_data[0] = 0; + for (int pos = 0; pos < OT_FLAGS_COUNT; ++pos) + { + int mask = 1 << pos; + int mode = Settings.ot_flags & (uint8_t)mask; + if (mode > 0) + { + if (addComma) + { + snprintf_P(mqtt_data, sizeof(mqtt_data), PSTR("%s,"), mqtt_data); + } + snprintf_P(mqtt_data, sizeof(mqtt_data), PSTR("%s%s"), mqtt_data, sns_opentherm_flag_text(mode)); + addComma = true; + } + } +} + +void sns_opentherm_set_central_heating_cmd(void) +{ + bool query = strlen(XdrvMailbox.data) == 0; + if (!query) + { + sns_ot_boiler_status.m_enableCentralHeating = atoi(XdrvMailbox.data); + } + ResponseCmndNumber(sns_ot_boiler_status.m_enableCentralHeating ? 1 : 0); +} + +/*********************************************************************************************\ + * Interface +\*********************************************************************************************/ + +bool Xsns68(uint8_t function) +{ + bool result = false; + if (FUNC_INIT == function) + { + if (sns_opentherm_Init()) + { + sns_opentherm_CheckSettings(); + } + } + + if (!sns_ot_master) + { + return result; + } + + switch (function) + { + case FUNC_LOOP: + sns_ot_master->process(); + break; + case FUNC_EVERY_100_MSECOND: + if (sns_ot_connection_status == OpenThermConnectionStatus::OTC_READY && sns_ot_master->isReady()) + { + unsigned long request = sns_opentherm_get_next_request(&sns_ot_boiler_status); + if (-1 != request) + { + sns_ot_master->sendRequestAync(request); + sns_ot_connection_status = OpenThermConnectionStatus::OTC_INFLIGHT; + } + } + break; + case FUNC_EVERY_SECOND: + if (sns_ot_connection_status == OpenThermConnectionStatus::OTC_DISCONNECTED) + { + // If disconnected, wait for the SNS_OT_DISCONNECT_COOLDOWN_SECONDS before the handshake + if (sns_ot_disconnect_cooldown == 0) + { + sns_ot_disconnect_cooldown = SNS_OT_DISCONNECT_COOLDOWN_SECONDS; + } + else if (--sns_ot_disconnect_cooldown == 0) + { + sns_ot_connection_status = OpenThermConnectionStatus::OTC_CONNECTING; + } + } + else if (sns_ot_connection_status == OpenThermConnectionStatus::OTC_CONNECTING) + { + sns_ot_start_handshake(); + } + break; + case FUNC_COMMAND: + result = DecodeCommand(kOpenThermCommands, OpenThermCommands); + break; + case FUNC_JSON_APPEND: + sns_opentherm_stat(1); + break; +#ifdef USE_WEBSERVER + case FUNC_WEB_SENSOR: + sns_opentherm_stat(0); + break; +#endif // USE_WEBSERVER + } + + return result; +} + +#endif // USE_OPENTHERM diff --git a/tasmota/xsns_68_opentherm_protocol.ino b/tasmota/xsns_68_opentherm_protocol.ino new file mode 100644 index 000000000..2693b086c --- /dev/null +++ b/tasmota/xsns_68_opentherm_protocol.ino @@ -0,0 +1,440 @@ +/* + xsns_68_opentherm_protocol.ino - OpenTherm protocol support for Tasmota + + Copyright (C) 2020 Yuriy Sannikov + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +#include "OpenTherm.h" + +#ifdef USE_OPENTHERM + +// Temperature tolerance. If temperature setpoint difference is less than the value, +// OT (1)(Control setpoint) command will be skipped +#define OPENTHERM_BOILER_SETPOINT_TOLERANCE 1.0 + +typedef union { + uint8_t m_flags; + struct + { + uint8_t notSupported : 1; // If set, boiler does not support this command + uint8_t supported : 1; // Set if at least one response were successfull + uint8_t retryCount : 2; // Retry counter before notSupported flag being set + }; +} OpenThermParamFlags; + +typedef union { + float m_float; + uint8_t m_u8; + uint16_t m_u16; + unsigned long m_ul; + bool m_bool; +} ResponseStorage; + +typedef struct OpenThermCommandT +{ + const char *m_command_name; + uint8_t m_command_code; + OpenThermParamFlags m_flags; + ResponseStorage m_results[2]; + unsigned long (*m_ot_make_request)(OpenThermCommandT *self, OT_BOILER_STATUS_T *boilerStatus); + void (*m_ot_parse_response)(OpenThermCommandT *self, OT_BOILER_STATUS_T *boilerStatus, unsigned long response); + void (*m_ot_appent_telemetry)(OpenThermCommandT *self); +} OpenThermCommand; + +OpenThermCommand sns_opentherm_commands[] = { + {// Get/Set Slave Status Flags + .m_command_name = "SLAVE", + .m_command_code = 0, + // OpenTherm ID(0) should never go into the notSupported state due to some connectivity issues + // otherwice it may lose boiler control + .m_flags = {.supported = 1}, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_set_slave_flags, + .m_ot_parse_response = sns_opentherm_parse_slave_flags, + .m_ot_appent_telemetry = sns_opentherm_tele_slave_flags}, + {// Set boiler temperature + .m_command_name = "BTMP", + .m_command_code = 0, + // OpenTherm ID(1) also should never go into the notSupported state due to some connectivity issues + .m_flags = {.supported = 1}, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_set_boiler_temperature, + .m_ot_parse_response = sns_opentherm_parse_set_boiler_temperature, + .m_ot_appent_telemetry = sns_opentherm_tele_boiler_temperature}, + {// Set Hot Water temperature + .m_command_name = "HWTMP", + .m_command_code = 0, + // OpenTherm ID(56) may not be supported + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_set_boiler_dhw_temperature, + .m_ot_parse_response = sns_opentherm_parse_boiler_dhw_temperature, + .m_ot_appent_telemetry = sns_opentherm_tele_boiler_dhw_temperature}, + {// Read Application-specific fault flags and OEM fault code + .m_command_name = "ASFF", + .m_command_code = 0, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_flags, + .m_ot_parse_response = sns_opentherm_parse_flags, + .m_ot_appent_telemetry = sns_opentherm_tele_flags}, + {// Read An OEM-specific diagnostic/service code + .m_command_name = "OEMD", + .m_command_code = 0, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_oem_diag, + .m_ot_parse_response = sns_opentherm_parse_oem_diag, + .m_ot_appent_telemetry = sns_opentherm_tele_oem_diag}, + {// Read Flame modulation + .m_command_name = "FLM", + .m_command_code = (uint8_t)OpenThermMessageID::RelModLevel, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_flame_modulation, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read Boiler Temperature + .m_command_name = "TB", + .m_command_code = (uint8_t)OpenThermMessageID::Tboiler, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_boiler_temperature, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read DHW temperature + .m_command_name = "TDHW", + .m_command_code = (uint8_t)OpenThermMessageID::Tdhw, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_generic_float, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read Outside temperature + .m_command_name = "TOUT", + .m_command_code = (uint8_t)OpenThermMessageID::Toutside, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_generic_float, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read Return water temperature + .m_command_name = "TRET", + .m_command_code = (uint8_t)OpenThermMessageID::Tret, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_generic_float, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read DHW setpoint + .m_command_name = "DHWS", + .m_command_code = (uint8_t)OpenThermMessageID::TdhwSet, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_dhw_setpoint, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + {// Read max CH water setpoint + .m_command_name = "TMAX", + .m_command_code = (uint8_t)OpenThermMessageID::MaxTSet, + .m_flags = 0, + .m_results = {{.m_u8 = 0}, {.m_u8 = 0}}, + .m_ot_make_request = sns_opentherm_get_generic_float, + .m_ot_parse_response = sns_opentherm_parse_generic_float, + .m_ot_appent_telemetry = sns_opentherm_tele_generic_float}, + +}; + +/////////////////////////////////// Process Slave Status Flags & Control ////////////////////////////////////////////////// +unsigned long sns_opentherm_set_slave_flags(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *status) +{ + bool centralHeatingIsOn = status->m_enableCentralHeating; + + if (status->m_useDiagnosticIndicationAsHeatRequest) { + centralHeatingIsOn |= OpenTherm::isDiagnostic(status->m_slave_raw_status); + } + + if (self->m_results[1].m_bool != centralHeatingIsOn) { + AddLog_P2(LOG_LEVEL_INFO, + PSTR("[OTH]: Central Heating transitioning from %s to %s"), + self->m_results[1].m_bool ? "on" : "off", + status->m_enableCentralHeating ? "on" : "off"); + } + self->m_results[1].m_bool = centralHeatingIsOn; + + unsigned int data = centralHeatingIsOn | + (status->m_enableHotWater << 1) | + (status->m_enableCooling << 2) | + (status->m_enableOutsideTemperatureCompensation << 3) | + (status->m_enableCentralHeating2 << 4); + + data <<= 8; + + return OpenTherm::buildRequest(OpenThermRequestType::READ, OpenThermMessageID::Status, data); +} + +void sns_opentherm_parse_slave_flags(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + boilerStatus->m_slave_raw_status = response; + self->m_results[0].m_ul = response; +} + +#define OT_FLAG_TO_ON_OFF(status, flag) ((((status) & (flag)) != 0) ? 1 : 0) +void sns_opentherm_tele_slave_flags(struct OpenThermCommandT *self) +{ + unsigned long st = self->m_results[0].m_ul; + ResponseAppend_P(PSTR("{\"FAULT\":%d,\"CH\":%d,\"DHW\":%d,\"FL\":%d,\"COOL\":%d,\"CH2\":%d,\"DIAG\":%d,\"RAW\":%lu}"), + OT_FLAG_TO_ON_OFF(st, 0x01), + OT_FLAG_TO_ON_OFF(st, 0x02), + OT_FLAG_TO_ON_OFF(st, 0x04), + OT_FLAG_TO_ON_OFF(st, 0x08), + OT_FLAG_TO_ON_OFF(st, 0x10), + OT_FLAG_TO_ON_OFF(st, 0x20), + OT_FLAG_TO_ON_OFF(st, 0x40), + st); +} + +/////////////////////////////////// Set Boiler Temperature ////////////////////////////////////////////////// +unsigned long sns_opentherm_set_boiler_temperature(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *status) +{ + // Assuming some boilers might write setpoint temperature into the Flash memory + // Having PID controlled appliance may produce a lot of small fluctuations in the setpoint value + // wearing out Boiler flash memory. + float diff = abs(status->m_boilerSetpoint - self->m_results[0].m_float); + // Ignore small changes in the boiler setpoint temperature + if (diff < OPENTHERM_BOILER_SETPOINT_TOLERANCE) + { + return -1; + } + AddLog_P2(LOG_LEVEL_INFO, + PSTR("[OTH]: Setting Boiler Temp. Old: %d, New: %d"), + (int)self->m_results[0].m_float, + (int)status->m_boilerSetpoint); + self->m_results[0].m_float = status->m_boilerSetpoint; + + unsigned int data = OpenTherm::temperatureToData(status->m_boilerSetpoint); + return OpenTherm::buildRequest(OpenThermMessageType::WRITE_DATA, OpenThermMessageID::TSet, data); +} +void sns_opentherm_parse_set_boiler_temperature(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[1].m_float = OpenTherm::getFloat(response); +} +void sns_opentherm_tele_boiler_temperature(struct OpenThermCommandT *self) +{ + char requested[FLOATSZ]; + dtostrfd(self->m_results[0].m_float, Settings.flag2.temperature_resolution, requested); + char actual[FLOATSZ]; + dtostrfd(self->m_results[1].m_float, Settings.flag2.temperature_resolution, actual); + + // indicate fault if tepmerature demand and actual setpoint are greater then tolerance + bool isFault = abs(self->m_results[1].m_float - self->m_results[0].m_float) > OPENTHERM_BOILER_SETPOINT_TOLERANCE; + + ResponseAppend_P(PSTR("{\"FAULT\":%d,\"REQ\":%s,\"ACT\": %s}"), + (int)isFault, + requested, + actual); +} + +/////////////////////////////////// Set Domestic Hot Water Temperature ////////////////////////////////////////////////// +unsigned long sns_opentherm_set_boiler_dhw_temperature(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *status) +{ + // The same consideration as for the boiler temperature + float diff = abs(status->m_hotWaterSetpoint - self->m_results[0].m_float); + // Ignore small changes in the boiler setpoint temperature + if (diff < OPENTHERM_BOILER_SETPOINT_TOLERANCE) + { + return -1; + } + AddLog_P2(LOG_LEVEL_INFO, + PSTR("[OTH]: Setting Hot Water Temp. Old: %d, New: %d"), + (int)self->m_results[0].m_float, + (int)status->m_hotWaterSetpoint); + + self->m_results[0].m_float = status->m_hotWaterSetpoint; + + unsigned int data = OpenTherm::temperatureToData(status->m_hotWaterSetpoint); + return OpenTherm::buildRequest(OpenThermMessageType::WRITE_DATA, OpenThermMessageID::TdhwSet, data); +} +void sns_opentherm_parse_boiler_dhw_temperature(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[1].m_float = OpenTherm::getFloat(response); +} +void sns_opentherm_tele_boiler_dhw_temperature(struct OpenThermCommandT *self) +{ + char requested[FLOATSZ]; + dtostrfd(self->m_results[0].m_float, Settings.flag2.temperature_resolution, requested); + char actual[FLOATSZ]; + dtostrfd(self->m_results[1].m_float, Settings.flag2.temperature_resolution, actual); + + ResponseAppend_P(PSTR("{\"REQ\":%s,\"ACT\": %s}"), + requested, + actual); +} + +/////////////////////////////////// App Specific Fault Flags ////////////////////////////////////////////////// +unsigned long sns_opentherm_get_flags(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *) +{ + return OpenTherm::buildRequest(OpenThermRequestType::READ, OpenThermMessageID::ASFflags, 0); +} + +void sns_opentherm_parse_flags(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + uint8_t fault_code = (response >> 8) & 0xFF; + uint8_t oem_fault_code = response & 0xFF; + boilerStatus->m_fault_code = fault_code; + boilerStatus->m_oem_fault_code = fault_code; + self->m_results[0].m_u8 = fault_code; + self->m_results[1].m_u8 = oem_fault_code; +} + +void sns_opentherm_tele_flags(struct OpenThermCommandT *self) +{ + ResponseAppend_P(PSTR("{\"FC\":%d,\"OFC\":%d}"), + (int)self->m_results[0].m_u8, + (int)self->m_results[1].m_u8); +} + +/////////////////////////////////// OEM Diag Code ////////////////////////////////////////////////// +unsigned long sns_opentherm_get_oem_diag(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *) +{ + return OpenTherm::buildRequest(OpenThermRequestType::READ, OpenThermMessageID::OEMDiagnosticCode, 0); +} + +void sns_opentherm_parse_oem_diag(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + uint16_t diag_code = (uint16_t)response & 0xFFFF; + boilerStatus->m_oem_diag_code = diag_code; + self->m_results[0].m_u16 = diag_code; +} + +void sns_opentherm_tele_oem_diag(struct OpenThermCommandT *self) +{ + ResponseAppend_P(PSTR("%d"), (int)self->m_results[0].m_u16); +} + +/////////////////////////////////// Generic Single Float ///////////////////////////////////////////////// +unsigned long sns_opentherm_get_generic_float(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *) +{ + return OpenTherm::buildRequest(OpenThermRequestType::READ, (OpenThermMessageID)self->m_command_code, 0); +} + +void sns_opentherm_parse_generic_float(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[0].m_float = OpenTherm::getFloat(response); +} + +void sns_opentherm_tele_generic_float(struct OpenThermCommandT *self) +{ + char str[FLOATSZ]; + dtostrfd(self->m_results[0].m_float, Settings.flag2.temperature_resolution, str); + ResponseAppend_P(PSTR("%s"), str); +} + +/////////////////////////////////// Specific Floats Rerports to the ///////////////////////////////////////////////// +void sns_opentherm_parse_dhw_setpoint(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[0].m_float = OpenTherm::getFloat(response); + boilerStatus->m_hotWaterSetpoint_read = self->m_results[0].m_float; +} + +void sns_opentherm_parse_flame_modulation(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[0].m_float = OpenTherm::getFloat(response); + boilerStatus->m_flame_modulation_read = self->m_results[0].m_float; +} + +void sns_opentherm_parse_boiler_temperature(struct OpenThermCommandT *self, struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + self->m_results[0].m_float = OpenTherm::getFloat(response); + boilerStatus->m_boiler_temperature_read = self->m_results[0].m_float; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////////////// + +#define SNS_OT_COMMANDS_COUNT (sizeof(sns_opentherm_commands) / sizeof(OpenThermCommand)) +int sns_opentherm_current_command = SNS_OT_COMMANDS_COUNT; + +unsigned long sns_opentherm_get_next_request(struct OT_BOILER_STATUS_T *boilerStatus) +{ + // get next and loop the command + if (++sns_opentherm_current_command >= SNS_OT_COMMANDS_COUNT) + { + sns_opentherm_current_command = 0; + } + + struct OpenThermCommandT *cmd = &sns_opentherm_commands[sns_opentherm_current_command]; + // Return error if command known as not supported + if (cmd->m_flags.notSupported) + { + return -1; + } + // Retrurn OT compatible request + return cmd->m_ot_make_request(cmd, boilerStatus); +} + +void sns_opentherm_check_retry_request() +{ + if (sns_opentherm_current_command >= SNS_OT_COMMANDS_COUNT) + { + return; + } + struct OpenThermCommandT *cmd = &sns_opentherm_commands[sns_opentherm_current_command]; + + bool canRetry = ++cmd->m_flags.retryCount < 3; + // In case of last retry and if this command never respond successfully, set notSupported flag + if (!canRetry && !cmd->m_flags.supported) + { + cmd->m_flags.notSupported = true; + AddLog_P2(LOG_LEVEL_ERROR, + PSTR("[OTH]: command %s is not supported by the boiler. Last status: %s"), + cmd->m_command_name, + sns_ot_master->statusToString(sns_ot_master->getLastResponseStatus())); + } +} + +void sns_opentherm_process_success_response(struct OT_BOILER_STATUS_T *boilerStatus, unsigned long response) +{ + if (sns_opentherm_current_command >= SNS_OT_COMMANDS_COUNT) + { + return; + } + struct OpenThermCommandT *cmd = &sns_opentherm_commands[sns_opentherm_current_command]; + // mark command as supported + cmd->m_flags.supported = true; + + cmd->m_ot_parse_response(cmd, boilerStatus, response); +} + +void sns_opentherm_dump_telemetry() +{ + bool add_coma = false; + for (int i = 0; i < SNS_OT_COMMANDS_COUNT; ++i) + { + struct OpenThermCommandT *cmd = &sns_opentherm_commands[i]; + if (!cmd->m_flags.supported) + { + continue; + } + + ResponseAppend_P(PSTR("%s\"%s\":"), add_coma ? "," : "", cmd->m_command_name); + + cmd->m_ot_appent_telemetry(cmd); + + add_coma = true; + } +} +#endif \ No newline at end of file