├── docs ├── block-diagram.png ├── re12001-snubber.jpg └── block-diagram.graffle ├── .gitignore ├── Makefile ├── README.md ├── css2cpp.js ├── style.css └── HeatingController.ino /docs/block-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/HeatingController/main/docs/block-diagram.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | node_modules 4 | 5 | # Generated file 6 | style_css.h 7 | -------------------------------------------------------------------------------- /docs/re12001-snubber.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/HeatingController/main/docs/re12001-snubber.jpg -------------------------------------------------------------------------------- /docs/block-diagram.graffle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njh/HeatingController/main/docs/block-diagram.graffle -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | style_css.h: style.css node_modules/csso 2 | node css2cpp.js $< $@ 3 | 4 | node_modules/csso: 5 | npm install csso 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Heating Controller 2 | ================== 3 | 4 | ![Block Diagram](docs/block-diagram.png) 5 | 6 | 7 | Relays 8 | ------ 9 | 10 | **Model**: Omron [G2R-1-S-DC12] 11 | **Coil Rating**: 12V DC / 43.2 mA 12 | **Contact Rating**: 10A at 250 VAC 13 | 14 | 15 | | Relay | Description | Device | Voltage | Current | 16 | |-------|----------------------|----------------------------|---------|---------------| 17 | | 1 | Boiler Call for heat | [Vaillant ecoTEC plus 831] | 230v AC | ? | 18 | | 2 | Radiator Zone Valve | [Honeywell V4043] | 230v AC | 6W / 0.042A | 19 | | 3 | Underfloor Heating | [Grundfos UPS2] | 230v AC | 0.06A - 0.42A | 20 | 21 | 22 | [Vaillant ecoTEC plus 831]: https://www.vaillant.co.uk/downloads/ecotec-installation-and-servicing-manual-261417.pdf 23 | [G2R-1-S-DC12]: https://www.ia.omron.com/product/item/5664/ 24 | [Honeywell V4043]: https://www.honeywelluk.com/products/Valves/Motorised-Valves/V4043-Motorised-Valves/ 25 | [Grundfos UPS2]: http://uk.grundfos.com/products/find-product/ups2.html 26 | -------------------------------------------------------------------------------- /css2cpp.js: -------------------------------------------------------------------------------- 1 | /* 2 | Script to take a CSS file, minify it and create C string to be placed in Program Memory 3 | */ 4 | 5 | const fs = require('fs') 6 | const path = require('path') 7 | const csso = require('csso') 8 | 9 | function escapeString (input) { 10 | const source = typeof input === 'string' || input instanceof String ? input : '' 11 | return source.replace(/"/g, '\\"') 12 | } 13 | 14 | const inputFilename = process.argv[2] || 'style.css' 15 | const shortName = path.basename(inputFilename).replace(/\W+/, '_').toLowerCase() 16 | const outputFilename = process.argv[3] || inputFilename.replace(/\.css$/, '_css') + '.cpp' 17 | console.log('Input file: ' + inputFilename) 18 | console.log('Output file: ' + outputFilename) 19 | 20 | const cssData = fs.readFileSync(inputFilename) 21 | const minifiedCss = csso.minify(cssData).css 22 | 23 | let output = `// This file was generated by css2cpp.js from ${inputFilename}\n` 24 | output += '#include \n' 25 | output += '\n' 26 | output += `const PROGMEM char pm_${shortName}[] = "${escapeString(minifiedCss)}";\n` 27 | 28 | fs.writeFile(outputFilename, output, function (err) { 29 | if (err) return console.log(err) 30 | console.log('Done.') 31 | }) 32 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body 2 | { 3 | padding: 0; 4 | margin: 0; 5 | font-family: Helvetica, Arial, sans-serif; 6 | font-size: 14pt; 7 | background: #fff; 8 | } 9 | 10 | h1 11 | { 12 | font-size: 28pt; 13 | background-color: #DDDEE6; 14 | padding: 10px; 15 | margin: 0 0 10px 0; 16 | border-bottom: 2px #888 solid; 17 | } 18 | 19 | table, tr { 20 | width: 100% 21 | } 22 | 23 | td, th { 24 | margin: 0px; 25 | padding: 10px; 26 | } 27 | 28 | td { text-align: center; } 29 | th { text-align: right; } 30 | 31 | .on { background-color: #6F6; } 32 | .off { background-color: #F00; } 33 | 34 | button 35 | { 36 | box-shadow: inset 0px 1px 0px 0px #54a3f7; 37 | background: linear-gradient(to bottom, #007dc1 5%, #0061a7 100%); 38 | background-color: #007dc1; 39 | border-radius: 3px; 40 | border: 1px solid #124d77; 41 | display: block; 42 | cursor: pointer; 43 | color: #ffffff; 44 | font-size: 13px; 45 | padding: 6px 24px; 46 | text-decoration: none; 47 | text-shadow: 0px 1px 0px #154682; 48 | } 49 | 50 | button:hover { 51 | background:linear-gradient(to bottom, #0061a7 5%, #007dc1 100%); 52 | background-color:#0061a7; 53 | } 54 | 55 | button:active { 56 | position:relative; 57 | top:1px; 58 | } 59 | -------------------------------------------------------------------------------- /HeatingController.ino: -------------------------------------------------------------------------------- 1 | /* 2 | * Heating Controller 3 | * 4 | * Relay Output 1 - D3 - Radiators Zone Valve 5 | * Relay Output 2 - D4 - Underfloor heating pump 6 | * (note: can't use D2 because it is connected to ENC28J60 INT pin) 7 | * 8 | * There is a UDP control interface on port 25910. 9 | * It accepts the following two-byte ASCII messages: 10 | * R1 - Turn the radiators On 11 | * R0 - Turn the radiators Off 12 | * U1 - Turn the Underfloor Heating On 13 | * U0 - Turn the Underfloor Heating Off 14 | */ 15 | 16 | #include 17 | //#include 18 | 19 | #include 20 | 21 | #include "style_css.h" 22 | 23 | #define UDP_PORT (25910) 24 | 25 | #define RADIATOR_RELAY_PIN (3) 26 | #define UNDERFLOOR_RELAY_PIN (4) 27 | 28 | #if ETHERSIA_MAX_PACKET_SIZE < 900 29 | #error EtherSia packet buffer is less than 900 bytes 30 | #endif 31 | 32 | /** ENC28J60 Ethernet Interface */ 33 | EtherSia_ENC28J60 ether(10); 34 | 35 | /** Define HTTP server */ 36 | HTTPServer http(ether); 37 | 38 | /** Define UDP socket - for receiving commands */ 39 | UDPSocket udpReciever(ether, UDP_PORT); 40 | 41 | /** Define UDP socket - for transmitting status */ 42 | UDPSocket udpSender(ether); 43 | 44 | /** Publish current status in next loop */ 45 | bool doPublish = true; 46 | 47 | 48 | void setup() 49 | { 50 | MACAddress macAddress("aa:d3:5a:f7:51:c5"); 51 | 52 | // Set relay pins to outputs 53 | pinMode(RADIATOR_RELAY_PIN, OUTPUT); 54 | pinMode(UNDERFLOOR_RELAY_PIN, OUTPUT); 55 | digitalWrite(RADIATOR_RELAY_PIN, LOW); 56 | digitalWrite(UNDERFLOOR_RELAY_PIN, LOW); 57 | 58 | Serial.begin(38400); 59 | Serial.println(F("[HeatingController]")); 60 | macAddress.println(); 61 | 62 | // Enable the Watchdog timer 63 | //wdt_enable(WDTO_8S); 64 | 65 | // Start Ethernet 66 | if (ether.begin(macAddress) == false) { 67 | Serial.println(F("Failed to configure Ethernet")); 68 | } 69 | 70 | Serial.print(F("Our address is: ")); 71 | ether.globalAddress().println(); 72 | 73 | if (udpSender.setRemoteAddress("house.aelius.co.uk", UDP_PORT)) { 74 | Serial.print(F("UDP status destination: ")); 75 | udpSender.remoteAddress().println(); 76 | } else { 77 | Serial.println(F("Error: failed to set UDP remote address")); 78 | } 79 | 80 | Serial.println(F("Ready.")); 81 | } 82 | 83 | void digitalToggle(byte pin) 84 | { 85 | digitalWrite(pin, !digitalRead(pin)); 86 | } 87 | 88 | 89 | void sendUdp(byte pin, char label) 90 | { 91 | char payload[3]; 92 | 93 | payload[0] = label; 94 | if (digitalRead(pin)) { 95 | payload[1] = '1'; 96 | } else { 97 | payload[1] = '0'; 98 | } 99 | payload[2] = '\0'; 100 | 101 | Serial.print(payload); 102 | udpSender.send(payload); 103 | } 104 | 105 | void publishStatusUdp() 106 | { 107 | Serial.print(F("Publishing status: ")); 108 | sendUdp(RADIATOR_RELAY_PIN, 'R'); 109 | Serial.print(' '); 110 | sendUdp(UNDERFLOOR_RELAY_PIN, 'U'); 111 | Serial.println(); 112 | } 113 | 114 | void printOnOffHtml(uint8_t pin) 115 | { 116 | bool state = digitalRead(pin); 117 | if (state) { 118 | http.print(F("On")); 119 | } else { 120 | http.print(F("Off")); 121 | } 122 | } 123 | 124 | void printIndex() 125 | { 126 | http.print(F("")); 127 | http.print(F("Heating Controller")); 128 | http.print(F("")); 129 | http.print(F("")); 130 | http.print(F("

Heating Controller

")); 131 | 132 | http.print(F("
")); 133 | 134 | http.print(F("")); 135 | printOnOffHtml(RADIATOR_RELAY_PIN); 136 | http.print(F("")); 137 | 138 | http.print(F("")); 139 | printOnOffHtml(UNDERFLOOR_RELAY_PIN); 140 | http.print(F("")); 141 | 142 | http.print(F("
Radiators
Underfloor Heating
")); 143 | } 144 | 145 | void sendPinStateReply(uint8_t pin) 146 | { 147 | bool state = digitalRead(pin); 148 | http.printHeaders(http.typePlain); 149 | if (state) { 150 | http.print(F("on")); 151 | } else { 152 | http.print(F("off")); 153 | } 154 | http.sendReply(); 155 | } 156 | 157 | void handleHttpPost(uint8_t pin) 158 | { 159 | if (http.body() == NULL) { 160 | digitalToggle(pin); 161 | } else if (http.bodyEquals("on")) { 162 | digitalWrite(pin, HIGH); 163 | } else if (http.bodyEquals("off")) { 164 | digitalWrite(pin, LOW); 165 | } 166 | http.redirect(F("/")); 167 | 168 | doPublish = true; 169 | } 170 | 171 | void handleHttp() 172 | { 173 | // GET the index page 174 | if (http.isGet(F("/"))) { 175 | http.printHeaders(http.typeHtml); 176 | printIndex(); 177 | http.sendReply(); 178 | 179 | } else if (http.isGet(F("/radiators"))) { 180 | sendPinStateReply(RADIATOR_RELAY_PIN); 181 | 182 | } else if (http.isGet(F("/underfloor"))) { 183 | sendPinStateReply(UNDERFLOOR_RELAY_PIN); 184 | 185 | } else if (http.isPost(F("/radiators"))) { 186 | handleHttpPost(RADIATOR_RELAY_PIN); 187 | 188 | } else if (http.isPost(F("/underfloor"))) { 189 | handleHttpPost(UNDERFLOOR_RELAY_PIN); 190 | 191 | } else if (http.isGet(F("/style.css"))) { 192 | http.printHeaders(http.typeCss); 193 | http.print(FPSTR(pm_style_css)); 194 | http.sendReply(); 195 | 196 | } else { 197 | // No matches - return 404 Not Found page 198 | http.notFound(); 199 | } 200 | } 201 | 202 | 203 | void handleUdpPacket() 204 | { 205 | char *payload = (char*)udpReciever.payload(); 206 | bool state; 207 | 208 | if (udpReciever.payloadLength() != 2) { 209 | // Invalid payload 210 | Serial.println(F("Received invalid UDP payload")); 211 | return; 212 | } 213 | 214 | if (payload[1] == '1') { 215 | state = HIGH; 216 | } else if (payload[1] == '0') { 217 | state = LOW; 218 | } else { 219 | // Invalid Payload 220 | Serial.println(F("Received invalid UDP device state")); 221 | return; 222 | } 223 | 224 | if (payload[0] == 'R') { 225 | // Change Radiator State 226 | digitalWrite(RADIATOR_RELAY_PIN, state); 227 | } else if (payload[0] == 'U') { 228 | // Change Underfloor State 229 | digitalWrite(UNDERFLOOR_RELAY_PIN, state); 230 | } else { 231 | // Invalid Payload 232 | Serial.println(F("Received invalid UDP device id")); 233 | return; 234 | } 235 | 236 | doPublish = true; 237 | } 238 | 239 | // the loop function runs over and over again forever 240 | void loop() 241 | { 242 | // Check for an available packet 243 | ether.receivePacket(); 244 | 245 | if (http.havePacket()) { 246 | handleHttp(); 247 | } else if (udpReciever.havePacket()) { 248 | handleUdpPacket(); 249 | } else { 250 | // Send back a ICMPv6 Destination Unreachable response 251 | // to any other connection attempts 252 | ether.rejectPacket(); 253 | } 254 | 255 | if (doPublish) { 256 | publishStatusUdp(); 257 | doPublish = false; 258 | } 259 | 260 | // Reset the watchdog 261 | //wdt_reset(); 262 | } 263 | --------------------------------------------------------------------------------