├── .gitmodules ├── .gitignore ├── secrets.yaml ├── README.md ├── vaillant.yaml └── vaillantx6.h /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "custom_components/syslog"] 2 | path = custom_components/syslog 3 | url = https://github.com/TheStaticTurtle/esphome_syslog.git 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gitignore settings for ESPHome 2 | # This is an example and may include too much for your use-case. 3 | # You can modify this file to suit your needs. 4 | /.esphome/ 5 | /secrets.yaml 6 | -------------------------------------------------------------------------------- /secrets.yaml: -------------------------------------------------------------------------------- 1 | wifi_ssid: "SSID" 2 | wifi_password: "secret12345" 3 | ap_password: "secret12345" 4 | 5 | api_password: "secret" 6 | ota_password: "secret" 7 | 8 | syslog_ip_address: "0.0.0.0" 9 | syslog_port: 514 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Control Vaillant heater via esphome 2 | My flat is equipped with a Vaillant VCW 204/3-E-HL gas boiler without any room temperature controller and a very power hungry circulator pump (~90W). I tried to bring down gas and power usage by controlling the heater via [Home-Assistant](https://www.home-assistant.io/) / [esphome](https://esphome.io/) according to my needs. 3 | 4 | This works by connecting the Vaillant X6 (debug) interface to an ESP32 to provide sensor data on various temperatures and states as well as controlling the output voltage to the Vaillant 7-8-9 interface to control the supply/flow temperature. 5 | 6 | It might also be possible to set the supply/flow temperature (and other parameters) directly via X6 but I did not try as using the 7-8-9 interface (which is designed for exactly that) seemed more safe to me. 7 | 8 | To great extend this is based on work by others: 9 | * PCB design for the Vaillant 7-8-9 interface: https://github.com/lal12/vaillant-heater-control-esp 10 | * Vaillant X6 interface: https://github.com/martin3000/ESPhome 11 | * Details on the Vaillant X6 protocol: https://old.ethersex.de/index.php/Vaillant_X6_Schnittstelle 12 | * Lots of discussion around this topic (in German): https://www.mikrocontroller.net/topic/126250?page=single 13 | 14 | -------------------------------------------------------------------------------- /vaillant.yaml: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: vaillant 3 | includes: 4 | - vaillantx6.h 5 | 6 | esp32: 7 | #board: az-delivery-devkit-v4 8 | board: esp32dev 9 | framework: 10 | #type: arduino 11 | type: esp-idf 12 | 13 | debug: 14 | update_interval: 10s 15 | 16 | # Enable logging 17 | logger: 18 | 19 | syslog: 20 | ip_address: !secret syslog_ip_address 21 | port: !secret syslog_port 22 | 23 | # Enable Home Assistant API 24 | api: 25 | encryption: 26 | key: !secret api_encryption_key 27 | 28 | ota: 29 | password: !secret ota_password 30 | 31 | wifi: 32 | ssid: !secret wifi_ssid 33 | password: !secret wifi_password 34 | 35 | # Enable fallback hotspot (captive portal) in case wifi connection fails 36 | ap: 37 | ssid: "Vaillant Fallback Hotspot" 38 | password: !secret ap_password 39 | 40 | #captive_portal: 41 | 42 | switch: 43 | - platform: restart 44 | name: "Vaillant Restart" 45 | 46 | text_sensor: 47 | - platform: debug 48 | reset_reason: 49 | name: "Reset Reason" 50 | 51 | sensor: 52 | - platform: uptime 53 | id: vaillant_uptime 54 | name: Vaillant Uptime 55 | unit_of_measurement: "s" 56 | - platform: wifi_signal 57 | id: wifi_signal_percent 58 | name: "WiFi Signal" 59 | # wifi_signal is reported as signal strength/RSSI in dB 60 | # convert to percent via filter 61 | filters: 62 | - lambda: return min(max(2 * (x + 100.0), 0.0), 100.0); 63 | unit_of_measurement: "%" 64 | update_interval: 60s 65 | entity_category: "diagnostic" 66 | device_class: "" 67 | - platform: debug 68 | free: 69 | name: "Heap Free" 70 | block: 71 | name: "Heap Max Block" 72 | loop_time: 73 | name: "Loop Time" 74 | # Vaillant stuff 75 | - name: "Vorlauf ist" 76 | id: vaill0 77 | platform: template 78 | unit_of_measurement: "°C" 79 | update_interval: 10s 80 | lambda: "return {};" 81 | - name: "Vorlauf set" 82 | id: vaill1 83 | platform: template 84 | unit_of_measurement: "°C" 85 | update_interval: 10s 86 | lambda: "return {};" 87 | - name: "Vorlauf soll" 88 | id: vaill2 89 | platform: template 90 | unit_of_measurement: "°C" 91 | update_interval: 10s 92 | lambda: "return {};" 93 | - name: "Vorlauf 789 soll" 94 | id: vaill3 95 | platform: template 96 | unit_of_measurement: "°C" 97 | update_interval: 10s 98 | lambda: "return {};" 99 | - name: "Rücklauf ist" 100 | id: vaill4 101 | platform: template 102 | unit_of_measurement: "°C" 103 | update_interval: 10s 104 | lambda: "return {};" 105 | - name: "Brauchwasser ist" 106 | id: vaill5 107 | platform: template 108 | unit_of_measurement: "°C" 109 | update_interval: 10s 110 | lambda: "return {};" 111 | - name: "Brauchwasser soll" 112 | id: vaill6 113 | platform: template 114 | unit_of_measurement: "°C" 115 | update_interval: 10s 116 | lambda: "return {};" 117 | - name: "Verbleibende Brennsperrzeit" 118 | id: mvaill0 119 | platform: template 120 | unit_of_measurement: "min" 121 | update_interval: 10s 122 | accuracy_decimals: 0 123 | lambda: "return {};" 124 | - name: "Used as input sensor for the PID component" 125 | id: pid_vorlauf_input 126 | internal: true 127 | platform: copy 128 | source_id: vaill3 129 | unit_of_measurement: "°C" 130 | filters: 131 | - median: 132 | window_size: 3 133 | send_every: 1 134 | 135 | binary_sensor: 136 | - name: "Brenner" 137 | id: bvaill0 138 | platform: template 139 | lambda: "return {};" 140 | - name: "Winter" 141 | id: bvaill1 142 | platform: template 143 | lambda: "return {};" 144 | - name: "Pumpe" 145 | id: bvaill2 146 | platform: template 147 | lambda: "return {};" 148 | 149 | custom_component: 150 | - lambda: |- 151 | auto vx6 = new Vaillantx6(id(x6_uart), 152 | id(vaill0),id(vaill1),id(vaill2),id(vaill3), 153 | id(vaill4),id(vaill5),id(vaill6), 154 | id(bvaill0),id(bvaill1),id(bvaill2), 155 | id(mvaill0)); 156 | App.register_component(vx6); 157 | return {vx6}; 158 | 159 | climate: 160 | - platform: pid 161 | id: vaillant_pid 162 | name: "PID Vorlauf" 163 | sensor: pid_vorlauf_input 164 | heat_output: vaillant_789 165 | default_target_temperature: "50°C" 166 | visual: 167 | min_temperature: 20 168 | max_temperature: 80 169 | temperature_step: 1 170 | control_parameters: 171 | output_averaging_samples: 3 172 | # No Overshoot PID 173 | kp: 0.00460 174 | ki: 0.00046 175 | kd: 0.02878 176 | deadband_parameters: 177 | threshold_high: 0.9°C 178 | threshold_low: -0.9°C 179 | kp_multiplier: 0.0 # proportional gain turned off inside deadband 180 | ki_multiplier: 0.05 # integral accumulates at only 5% of normal ki 181 | kd_multiplier: 0.0 # derviative is turned off inside deadband 182 | deadband_output_averaging_samples: 15 # average the output over 15 samples within the deadband 183 | 184 | uart: 185 | id: x6_uart 186 | tx_pin: GPIO17 187 | rx_pin: GPIO16 188 | baud_rate: 9600 189 | 190 | output: 191 | - platform: ledc 192 | pin: GPIO27 193 | id: vaillant_789 194 | frequency: 30kHz 195 | zero_means_zero: true 196 | min_power: 0.1 197 | max_power: 0.8 198 | -------------------------------------------------------------------------------- /vaillantx6.h: -------------------------------------------------------------------------------- 1 | #include "esphome.h" 2 | #include 3 | 4 | #define CMD_LENGTH 7 5 | #define ANSWER_LENGTH 8 6 | #define RETURN_TYPE_COUNT 3 7 | 8 | typedef unsigned char byte; 9 | 10 | void logCmd(const char *tag, byte *cmd) 11 | { 12 | ESP_LOGD("Vaillantx6", "%s: 0x%.2x 0x%.2x 0x%.2x 0x%.2x 0x%.2x 0x%.2x 0x%.2x", tag, cmd[0], cmd[1], cmd[2], cmd[3], cmd[4], cmd[5], cmd[6]); 13 | } 14 | 15 | enum VaillantReturnTypes 16 | { 17 | None = 0, 18 | Temperature, 19 | SensorState, 20 | Bool, 21 | Minutes 22 | }; 23 | 24 | uint8_t VaillantReturnTypeLength(VaillantReturnTypes t) 25 | { 26 | switch (t) 27 | { 28 | case SensorState: 29 | case Bool: 30 | case Minutes: 31 | return 1; 32 | case Temperature: 33 | return 2; 34 | default: 35 | return 0; 36 | } 37 | } 38 | 39 | float VaillantParseTemperature(byte *answerBuff, uint8_t offset) 40 | { 41 | int16_t i = (answerBuff[offset] << 8) | answerBuff[offset + 1]; 42 | return i / (16.0f); 43 | } 44 | 45 | int VaillantParseBool(byte *answerBuff, uint8_t offset) 46 | { 47 | switch (answerBuff[offset]) 48 | { 49 | case 0xF0: 50 | case 0x00: 51 | return 0; 52 | case 0x0F: 53 | case 0x01: 54 | return 1; 55 | default: 56 | ESP_LOGE("VaillantParseBool", "Unable to parse a bool from 0x%.2x", answerBuff[offset]); 57 | return -1; 58 | } 59 | } 60 | 61 | struct VaillantCommand 62 | { 63 | std::string Name; 64 | byte Address; 65 | VaillantReturnTypes ReturnTypes[RETURN_TYPE_COUNT]; 66 | // SensorID contains the ID of the sensor to use, corresponding to the ReturnType. 67 | // Use -1 to not assign a sensor. 68 | int SensorID[RETURN_TYPE_COUNT]; 69 | }; 70 | 71 | const VaillantCommand vaillantCommands[] = { 72 | {"Vorlauf Ist", 0x18, {Temperature, SensorState, None}, {0, -1, -1}}, 73 | {"Vorlauf Set", 0x19, {Temperature, None, None}, {1, -1, -1}}, 74 | {"Vorlauf Soll", 0x39, {Temperature, None, None}, {2, -1, -1}}, 75 | {"Vorlauf 789 Soll", 0x25, {Temperature, None, None}, {3, -1, -1}}, 76 | {"Rücklauf Ist", 0x98, {Temperature, Temperature, SensorState}, {4, -1, -1}}, 77 | {"Brauchwasser Ist", 0x16, {Temperature, SensorState, None}, {5, -1, -1}}, 78 | {"Brauchwasser Soll", 0x01, {Temperature, None, None}, {6, -1, -1}}, 79 | {"Brenner", 0x0d, {Bool, None, None}, {0, -1, -1}}, 80 | {"Winter", 0x08, {Bool, None, None}, {1, -1, -1}}, 81 | {"Pumpe", 0x44, {Bool, None, None}, {2, -1, -1}}, 82 | {"Verbliebene Brennsperrzeit", 0x38, {Minutes, None, None}, {0, -1, -1}}, 83 | }; 84 | const byte vaillantCommandsSize = sizeof(vaillantCommands) / sizeof *(vaillantCommands); 85 | 86 | class Vaillantx6 : public PollingComponent, 87 | public UARTDevice 88 | { 89 | // Sensors as provided by custom_component lambda call 90 | Sensor *temperatureSensors[8]; 91 | BinarySensor *binarySensors[3]; 92 | Sensor *minutesSensor[1]; 93 | 94 | // All command start with startBytes sequence 95 | const byte startBytes[4] = {0x07, 0x00, 0x00, 0x00}; 96 | 97 | public: 98 | Vaillantx6(UARTComponent *parent, 99 | Sensor *tSensor0, Sensor *tSensor1, Sensor *tSensor2, Sensor *tSensor3, 100 | Sensor *tSensor4, Sensor *tSensor5, Sensor *tSensor6, 101 | BinarySensor *bSensor0, BinarySensor *bSensor1, BinarySensor *bSensor2, 102 | Sensor *mSensor0) 103 | : PollingComponent(10000), UARTDevice(parent) 104 | { 105 | // Temperature Sensors 106 | temperatureSensors[0] = tSensor0; // Vorlauf ist 107 | temperatureSensors[1] = tSensor1; // Vorlauf set 108 | temperatureSensors[2] = tSensor2; // Vorlauf soll 109 | temperatureSensors[3] = tSensor3; // Vorlauf 789 soll 110 | temperatureSensors[4] = tSensor4; // Ruecklauf ist 111 | temperatureSensors[5] = tSensor5; // Brauchwasser ist 112 | temperatureSensors[6] = tSensor6; // Brauchwasser soll 113 | // Binary sensors 114 | binarySensors[0] = bSensor0; // Brenner 115 | binarySensors[1] = bSensor1; // Winter 116 | binarySensors[2] = bSensor2; // Pumpe 117 | // Minute sensors 118 | minutesSensor[0] = mSensor0; // Verbleibende Brennsperrzeit 119 | } 120 | 121 | /** 122 | * Compute the checksum used for Vaillant commands (and responses) 123 | * 124 | * @param data Array of bytes to compute the checksump for 125 | * @param len How many bytes of data to compute the checksum for 126 | * @return The 1 byte checksum 127 | **/ 128 | byte checksum(byte *data, byte len) 129 | { 130 | byte checksum = 0; 131 | byte i = 0; 132 | for (i = 0; i < len; i++) 133 | { 134 | if (checksum & 0x80) 135 | { 136 | // checksum = ((checksum << 1) | 1) & 0xff; 137 | checksum = (checksum << 1) | 1; 138 | checksum = checksum ^ 0x18; 139 | } 140 | else 141 | { 142 | checksum = checksum << 1; 143 | } 144 | 145 | checksum = checksum ^ data[i]; 146 | } 147 | return checksum; 148 | } 149 | 150 | bool checksumOk(byte *answerBuff, byte len) 151 | { 152 | return checksum(answerBuff, len - 1) == answerBuff[len - 1]; 153 | } 154 | 155 | /** 156 | * Create a command (or request) packet to be send to the Vaillant device 157 | * @param packet Pointer to an array of CMD_LENGTH bytes where the resulting packet is stored 158 | * @param address The address of the command/request to be executed on Vaillant 159 | * @return CMD_LENGTH 160 | **/ 161 | int buildPacket(byte *packet, byte address) 162 | { 163 | int i = 0; 164 | 165 | // Copy start sequence 166 | while (i < sizeof(startBytes)) 167 | { 168 | packet[i] = startBytes[i]; 169 | i++; 170 | } 171 | // The actual address of the command to call 172 | packet[i] = address; 173 | i++; 174 | // There is one byte of 0x00 before the checksum 175 | packet[i] = 0x00; 176 | i++; 177 | packet[i] = checksum(packet, 6); 178 | return i; 179 | } 180 | 181 | // Allocate an buffer big enough to fit the answer packet 182 | // byte *answerBuff = (byte *)malloc(sizeof(byte *) * answerLen >= 8); 183 | /** 184 | * Send a command package (from buildPackage) to Vaillant and fetch the answer 185 | * @param answerBuff Pointer to an array of at least ANSWER_LENGTH bytes wher the answer is stored 186 | * @param packet The command packet to be send to Vaillant 187 | * @return Number of bytes read into answerBuff, -1 in case the answer was > 8 bytes long and -2 in case of checksum missmatch 188 | */ 189 | int sendPacket(byte *answerBuff, byte *packet) 190 | { 191 | int answerLen = 0; 192 | int readRetry = 3; 193 | // Send the command packet to Vaillant 194 | write_array(packet, CMD_LENGTH); 195 | 196 | // Wait for the first byte to arrive to parse the lenght of the answer 197 | while (available() < 1) 198 | { 199 | delay(50); 200 | readRetry--; 201 | if (readRetry < 0) 202 | { 203 | ESP_LOGE("Vaillantx6 sendPacket", "Timed out waiting for bytes from Vaillant"); 204 | // TODO: add a sensor for errors 205 | return -3; 206 | } 207 | } 208 | answerLen = peek(); 209 | 210 | // Safety net. Have not seen anything longer than 8 bytes 211 | // coming from Vaillant 212 | if (answerLen > ANSWER_LENGTH) 213 | { 214 | ESP_LOGE("Vaillantx6 sendPacket", "Received an answer of unexpected length %d, ignoring", answerLen); 215 | // Empty the buffer to ensure a clean start on next run 216 | while (available()) 217 | { 218 | read(); 219 | } 220 | return -1; 221 | } 222 | // Read the complete answer (including length and checksum) 223 | read_array(answerBuff, answerLen); 224 | if (!checksumOk(answerBuff, answerLen)) 225 | { 226 | ESP_LOGE("Vaillantx6 sendPacket", "Packet has invalid checksum"); 227 | // Can't be sure that the calculated length was correct in the first place, 228 | // so make sure the buffer is empty before trying again 229 | while (available()) 230 | { 231 | read(); 232 | } 233 | return -2; 234 | } 235 | return answerLen; 236 | } 237 | 238 | void update() override 239 | { 240 | int answerLen = 0; 241 | byte *cmdPacket = (byte *)malloc(sizeof(byte *) * CMD_LENGTH); 242 | byte *answerBuff = (byte *)malloc(sizeof(byte *) * ANSWER_LENGTH); 243 | 244 | for (int i = 0; i < vaillantCommandsSize; i++) 245 | { 246 | buildPacket(cmdPacket, vaillantCommands[i].Address); 247 | logCmd(vaillantCommands[i].Name.c_str(), cmdPacket); 248 | 249 | answerLen = sendPacket(answerBuff, cmdPacket); 250 | if (answerLen < 0) 251 | { 252 | ESP_LOGE("Vaillantx6", "sendPacket returned an error: %d", answerLen); 253 | continue; 254 | } 255 | else if (answerLen <= 3) 256 | { 257 | ESP_LOGW("Vaillantx6", "Anwer is too short (%d bytes)", answerLen); 258 | continue; 259 | } 260 | 261 | // Parse data 262 | for (int t = 0; t < RETURN_TYPE_COUNT; t++) 263 | { 264 | int sensorID = vaillantCommands[i].SensorID[t]; 265 | if (sensorID < 0) 266 | { 267 | ESP_LOGI("Vaillantx6", "%s: No sensor for type id %d", vaillantCommands[i].Name.c_str(), t); 268 | continue; 269 | } 270 | 271 | switch (vaillantCommands[i].ReturnTypes[t]) 272 | { 273 | case None: 274 | case SensorState: 275 | // FIXME: This ignores the sensor state, it probably should not. 276 | // Exit the loop on first None type, there won't be more 277 | goto exit_type_loop; 278 | case Temperature: 279 | temperatureSensors[sensorID]->publish_state(VaillantParseTemperature(answerBuff, 2)); 280 | // Exit the loop after parsing a temperature (0x98 has two, but I don't know the meaning of the second) 281 | goto exit_type_loop; 282 | case Bool: 283 | { 284 | int b = VaillantParseBool(answerBuff, 2); 285 | if (b < 0) 286 | continue; 287 | binarySensors[sensorID]->publish_state(b); 288 | goto exit_type_loop; 289 | } 290 | case Minutes: 291 | minutesSensor[sensorID]->publish_state(answerBuff[2]); 292 | goto exit_type_loop; 293 | } 294 | } 295 | exit_type_loop:; 296 | } 297 | 298 | free(cmdPacket); 299 | free(answerBuff); 300 | } 301 | }; 302 | --------------------------------------------------------------------------------