From b52794998e150fb97c675da7e88db3b8b6f8990c Mon Sep 17 00:00:00 2001 From: proddy Date: Sat, 22 Dec 2018 16:00:26 +0100 Subject: [PATCH] Version 1.1. See ChangeLog --- CHANGELOG.md | 67 ++ README.md | 131 +-- doc/home_assistant/automations.yaml | 22 - doc/home_assistant/input_number.yaml | 9 - doc/home_assistant/ui-lovelace.yaml | 6 +- doc/telnet/telnet_example.jpg | Bin 70914 -> 73952 bytes espurna/boiler-espurna.ino | 3 +- platformio.ini-example | 10 +- src/ESPHelper.cpp | 22 +- src/ESPHelper.h | 12 +- src/boiler.ino | 493 +++++------ src/ems.cpp | 1226 ++++++++++++++++---------- src/ems.h | 157 ++-- src/my_config.h | 19 +- src/version.h | 2 + 15 files changed, 1282 insertions(+), 897 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 doc/home_assistant/input_number.yaml create mode 100644 src/version.h diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a35d4d7f3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# EMS-ESP-Boiler Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Setting the mode and setpoint temperature on a RC35 + +## [1.1.0] 2018-12-22 + +### Fixed + +- Fixed handling of negative flaoting point values (like outdoor temp) +- Fixed handling of auto & manual mode on an RC30 +- [Fixed condition where all telegram types were processed, instead of only broadcasts or our own reads](https://github.com/proddy/EMS-ESP-Boiler/issues/15) + +### Added + +- Created this CHANGELOG.md file! +- [Added support for the Nefit Easy thermostat, reading of temperature values only](https://github.com/proddy/EMS-ESP-Boiler/issues/9) - note *read only* (big thanks @**kroon040** for lending me an Easy device) +- [Added support for RC35/Moduline 400](https://github.com/proddy/EMS-ESP-Boiler/issues/14) - *read only* +- [New raw logging mode for logging](https://github.com/proddy/EMS-ESP-Boiler/issues/11) +- [New 'r'command to send raw data to EMS](https://github.com/proddy/EMS-ESP-Boiler/issues/11) +- [Added MQTT messages for hot water on and heating on](https://github.com/proddy/EMS-ESP-Boiler/issues/10) +- Implemented FIFO circular buffer queue for up to 20 Tx messages (Q command to show queue) +- Toggle Tx transmission via telnet (use 'X' command) +- Show thermostat type in help stats (use 's' command) +- Show version is help stats (use '?' command) + +### Changed + +- Improved overall formatting of logging +- Include app name and version in telnet help +- Improved method to switch off hot tap water in Shower Alert +- Telnet P and M commands have changed +- Enabling Logging in telnet is now the 'l' command +- Logging is set back to None when telnet session closes +- Improved fetching of initial boiler values to post to MQTT at startup +- Improved handling and retrying of write/Tx commands + +### Removed + +- Hid access from telnet to the Experimental custom function command 'x' +- Tx and Rx stats have gone from the stats page, as they were pretty meaningless +- Removed NO_TX define in platformio and replaced with system parameter 'command X' +- Removed -DDEBUG option in build +- Removed wwtemp MQTT messages to set the boiler temp. You'll never miss it. +- Removed LEDs for Tx, Rx and Err. Too many flashing lights and it drains the current. +- Removed capturing of last Rx and Tx times +- Support for older RC20 thermostats + +### Known Issues + +- There's a nasty memory leek when in telnet's verbose mode with sending which causes the EMS to reset when running for a while. + +## [1.0.1] 2018-09-24 + +- Initial stable version + +## [0.1.0] 2018-05-14 + +- Initial development version \ No newline at end of file diff --git a/README.md b/README.md index d06d27996..df9114d5a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # EMS-ESP-Boiler -EMS-ESP-Boiler is an controller running on an ESP8266 to communicate with EMS (Energy Management System) based Boilers from the Bosch range. This includes the Buderus and Nefit ranger of boilers, heaters and thermostats. +EMS-ESP-Boiler is a project to build a controller circuit running with an ESP8266 to communicate with EMS (Energy Management System) based Boilers and Thermostats from the Bosch range and compatibles such as Buderus, Nefit, Junkers etc. -There are 3 parts to this project, first the design of the circuit, second the code to deploy to an ESP8266 based microcontroller and lastly settings for Home Assistant to monitor data and issue direct commands via MQTT. +There are 3 parts to this project, first the design of the circuit, second the code for the ESP8266 microcontroller firmware and lastly an example configuration for Home Assistant to monitor the data and issue direct commands via MQTT. -[![version](https://img.shields.io/badge/version-1.1.2-brightgreen.svg)](CHANGELOG.md) +[![version](https://img.shields.io/badge/version-1.1.0-brightgreen.svg)](CHANGELOG.md) [![branch](https://img.shields.io/badge/branch-dev-orange.svg)](https://github.org/xoseperez/espurna/tree/dev/) [![license](https://img.shields.io/github/license/xoseperez/espurna.svg)](LICENSE) @@ -23,7 +23,11 @@ There are 3 parts to this project, first the design of the circuit, second the c - [EMS Reading and Writing](#ems-reading-and-writing) - [The ESP8266 Source Code](#the-esp8266-source-code) - [Supported EMS Types](#supported-ems-types) - - [Supporting other Thermostats types](#supporting-other-thermostats-types) + - [Supported Thermostats](#supported-thermostats) + - [RC20 (Moduline 300)](#rc20-moduline-300) + - [RC30 (Moduline 400)](#rc30-moduline-400) + - [RC35](#rc35) + - [TC100/TC200 (Nefit Easy)](#tc100tc200-nefit-easy) - [Customizing The Code](#customizing-the-code) - [Using MQTT](#using-mqtt) - [The Basic Shower Logic](#the-basic-shower-logic) @@ -33,28 +37,30 @@ There are 3 parts to this project, first the design of the circuit, second the c - [Using ESPurna](#using-espurna) - [Using Pre-built Firmware](#using-pre-built-firmware) - [Building Using Arduino IDE](#building-using-arduino-ide) - - [Known Issues and ToDo's](#known-issues-and-todos) + - [Known Issues](#known-issues) + - [Wish List](#wish-list) - [Your Comments and Feedback](#your-comments-and-feedback) + - [DISCLAIMER](#disclaimer) ## Introduction -My original intention for this home project with to build my own smart thermostat for my Nefit Trendline boiler and then have it controlled via [Home Assistant](https://www.home-assistant.io/) on my mobile phone. I had a few ESP32s and ESP8266s lying around from previous IoT projects and building a specific circuit to decode the EMS messages was a nice challenge into more complex electronic circuits. I then began adding new features such as timing how long the shower was running for and triggering an alarm (actually a shot of cold water!) after a certain duration. +My original intention for this home project with to build my own smart thermostat for my Nefit Trendline boiler and then have it controlled automatically via [Home Assistant](https://www.home-assistant.io/) on my mobile phone. I had a few ESP32s and ESP8266s lying around from previous IoT projects and building a specific circuit to decode the EMS messages was a nice challenge into designing complete end-to-end complex electronic circuits. I then began adding new features such as timing how long the shower would be running for and subsequently triggering an alarm (actually a shot of cold water) after a certain period. -Acknowledgments and kudos to the following people and their open-sourced projects: +Acknowledgments and kudos to the following people and their open-sourced projects that have helped me get this far: - **susisstrolch** - Probably the first working version of the EMS bridge circuit I could find designed for the ESP8266. I borrowed Juergen's [schematic](https://github.com/susisstrolch/EMS-ESP12) and parts of his code logic. + **susisstrolch** - Probably the first working version of the EMS bridge circuit I found designed for the ESP8266. I borrowed Juergen's [schematic](https://github.com/susisstrolch/EMS-ESP12) and parts of his code logic. - **bbqkees** - Kees built a [circuit](https://github.com/bbqkees/Nefit-Buderus-EMS-bus-Arduino-Domoticz) and wrote some sample Arduino code to read from the EMS and push messages to Domoticz. His SMD board is also available to purchase from him directly. + **bbqkees** - Kees built a [circuit](https://github.com/bbqkees/Nefit-Buderus-EMS-bus-Arduino-Domoticz) and some sample Arduino code to read from the EMS and push messages to Domoticz. His SMD board is also now available for purchase. **EMS Wiki** - A comprehensive [reference](https://emswiki.thefischer.net/doku.php?id=wiki:ems:telegramme) for decoding the EMS telegrams, which I found not always to be 100% accurate. It's in German so use Google Translate if you need help. ## Supported Boilers Types -Most Bosch branded boilers that support the Logamatic EMS (and EMS+) bus protocols work with this design. Which are Nefit, Buderus, Worcester and Junkers and copyrighted. +Most Bosch branded boilers that support the Logamatic EMS (and EMS+) bus protocols work with this design. Which are Nefit, Buderus, Worcester and Junkers and copyrighted. Please make sure you read the **Disclaimer** carefully before sending ambigious messages to your EMS bus as you cause device damage. ## Supported ESP8266 devices -I've tested the code and circuit with a few ESP8266 development boards such as the Wemos D1 Mini, Wemos D1 Mini Pro, Nodemcu0.9 and Nodemcu2 boards. It will also work on bare ESP8266 chips such as the 12s but do make sure you disabled the LED support and wire the UART correctly as this is switched (explained below). +I've tested the code and circuit with a few ESP8266 development boards such as the Wemos D1 Mini, Wemos D1 Mini Pro, Nodemcu0.9 and Nodemcu2 boards. It will also work on bare ESP8266 chips such as the E-12s but do make sure you disabled the LED support and wire the UART correctly as the code doesn't use the normal Rx and Tx pins. This is explained below. ## Getting Started @@ -72,22 +78,23 @@ Use the telnet client to inform you of all activity and errors real-time. This i ![Telnet](doc/telnet/telnet_example.jpg) -If you type 'v 3' and Enter, it will toggle verbose logging showing you more detailed messages. I use ANSI colors with white text for info messages, green for well formatted telegram packages (which have validated CRC checks), red for corrupt packages and yellow for send responses. +If you type 'l 4' and Enter, it will toggle verbose logging showing you more detailed messages. I use ANSI colors with white text for info messages, green for well formatted telegram packages (which have validated CRC checks), red for corrupt packages and yellow for send responses. ![Telnet](doc/telnet/telnet_verbose.PNG) To see the current values of the Boiler and its parameters type 's' and hit Enter. Watch out for unsuccessful telegram packets in the #CrcErrors line. + ![Telnet](doc/telnet/telnet_stats.PNG) Commands can be issued directly to the EMS bus typing in a letter followed by an optional parameter and pressing Enter. Supported commands are: -- **r** to send a read command to the boiler. The 2nd parameter is the type. For example 'b 33' will request type UBAParameterWW and bring back the Warm Water temperatures from the Boiler. +- **b** to send a read command to the boiler. The 2nd parameter is the type. For example 'b 33' will request type UBAParameterWW and bring back the Warm Water temperatures from the Boiler. - **t** is similar, but to send a read command to the thermostat. - **T** set the thermostat temperature to the given celsius value - **w** to adjust the temperature of the warm water from the boiler -- **a** to turn the warm water on and off +- **a** to turn the warm tap water on and off - **h** to list all the recognized EMS types -- **p** to toggle the Polling response on/off (note it's not necessary to have Polling enabled to work) +- **P** to toggle the Polling response on/off (note it's not necessary to have Polling enabled to work) - **m** to set the thermostat mode to manual or auto - **S** to toggle the Shower Timer functionality on/off - **A** to toggle the Shower Timer Alert functionality on/off @@ -118,7 +125,8 @@ And lastly if you don't fancy building the circuit, [bbqkees](http://www.domotic ### Powering The EMS Circuit -The EMS circuit will work with both 3.3V and 5V. It's easiest though to power directly from the ESP8266's 3V3 line and run a steady 5V into the microcontroller. Powering the ESP8266 microcontroller can be either +The EMS circuit will work with both 3.3V and 5V. It's easiest though to power directly from the ESP8266's 3V3 line and run a steady 5V into the microcontroller. Powering the ESP8266 microcontroller can be either: + - via the USB if your dev board has one - using an external 5V power supply into the 5V vin on the board - powering from the 3.5" service jack on the boiler. This will give you 8V so you need a buck converter (like a [Pololu D24C22F5](https://www.pololu.com/product/2858)) to step this down to 5V to provide enough power to the ESP8266 (250mA at least) @@ -128,7 +136,6 @@ The EMS circuit will work with both 3.3V and 5V. It's easiest though to power di | ------------------------------------------ | | ![Power circuit](doc/schematics/Schematic_EMS-ESP-Boiler-supercap.png) | - ## How The EMS Bus Works Packages are sent to the EMS "bus" from the Boiler and any other compatible connected devices via serial TTL transmission. The protocol is 9600 baud, 8N1 (8 bytes, no parity, 1 stop bit). Each package is terminated with a break signal ``, a 11-bit long low signal of zeros. @@ -151,7 +158,7 @@ Our circuit acts as a service key and thus uses an ID 0x0B. This ID is reserved ### EMS Polling -The bus master (boiler) sends out a poll request every second by sending out a sequential list of all possible IDs as a single byte followed by the break signal. The ID always has its high 7th bit set so in the code we're looking for 1 byte messages matching the format `[dest|0x80] `. +The bus master (boiler) sends out a poll request every second by sending out a sequential list of all possible IDs as a single byte followed by the break signal. The ID always has its high 8th bit (MSB) set so in the code we're looking for 1 byte messages matching the format `[dest|0x80] `. Any connected device can respond to a Polling call with an acknowledging by sending back a single byte with its own ID. In our case we would listen for a `[0x8B] ` (meaning us) and then send back `[0x0B] ` to say we're alive and ready. Although I found this is not needed for normal operation so it's disabled as default in the code. @@ -175,7 +182,7 @@ The tables below shows which types are broadcasted regularly by the boiler (ID 0 | Source (ID) | Type ID | Name | Description | Frequency | | ----------------- | ------- | ----------------- | --------------------------------------------------- | ---------- | | Thermostat (0x17) | 0x06 | RCTime | returns time and date on the thermostat | 60 seconds | -| Thermostat (0x17) | 0x91 | RC20StatusMessage | returns current and set temperatures | 60 seconds | +| Thermostat (0x17) | 0x91 | RC30StatusMessage | returns current and set temperatures | 60 seconds | | Thermostat (0x17) | 0xA3 | RCTempMessage | returns temp values from external (outdoor) sensors | 60 seconds | Refer to the code in `ems.cpp` for further explanation on how to parse these message types and also reference the EMS Wiki. @@ -184,15 +191,15 @@ Refer to the code in `ems.cpp` for further explanation on how to parse these mes Telegram packets can only be sent after the Boiler sends a poll to the sending device. The response can be a read command to request data or a write command to send data. At the end of the transmission a poll response is sent from the client (` `) to say we're all done and free up the bus for other clients. -When doing a request to read data the `[src]` is our device (0x0B) and the `[dest]` has it's 7-bit set. Say we were requesting data from the thermostat we would use `[dest] = 0x97` since RC30 has an ID of 0x17. +When doing a request to read data the `[src]` is our device `(0x0B)` and the `[dest]` must have has it's MSB (8th bit) set. Say we were requesting data from the thermostat we would use `[dest] = 0x97` since RC30 has an ID of 0x17. -When doing a write request, the 7th bit is masked in the `[dest]`. After this write request the destination device will send either a single byte 0x01 for success or 0x04 for fail. +Following a write request, the `[dest]` doesn't have the 8th bit set and after this write request the destination device will send either a single byte 0x01 for success or 0x04 for failure. Every telegram sent is echo'd back to Rx. ## The ESP8266 Source Code -*Disclaimer*: This code here is really for reference only, I don't expect anyone to use as is since it's highly tailored to my environment and my needs. Most of the code however is self explanatory with comments here and there. If you wish to make some changes start with the `defines` and `const` sections at the top of `boiler.ino`. +*Disclaimer*: This code here is really for reference only, I don't expect anyone to use "as is" since it's highly tailored to my environment and my needs. Most of the code however is self explanatory with comments here and there in the code. The code is built on the Arduino framework and is dependent on these external libraries: @@ -213,27 +220,45 @@ The code is built on the Arduino framework and is dependent on these external li `ems.cpp` defines callback functions that handle all the broadcast types listed above (e.g. 0x34, 0x18, 0x19 etc) plus these extra types: -| Source (ID) | Type ID | Name | Description | -| ----------------- | ------- | ----------------------------- | ---------------------------------------- | -| Boiler (0x08) | 0x33 | UBAParameterWW | reads selected & desired warm water temp | -| Boiler (0x08) | 0x14 | UBATotalUptimeMessage | | -| Boiler (0x08) | 0x15 | UBAMaintenanceSettingsMessage | | -| Boiler (0x08) | 0x16 | UBAParametersMessage | | -| Thermostat (0x17) | 0xA8 | RC20Temperature | sets operating modes for a RC20 & RC30 | -| Thermostat (0x17) | 0x02 | Version | reads Version major/minor | -| Thermostat (0x18) | 0x0A | EasyTemperature | thermostat monitor for an TC100/Easy | +| Source (ID) | Type ID | Name | Description | +| ----------------- | ---------------- | ----------------------------- | ---------------------------------------- | +| Boiler (0x08) | 0x33 | UBAParameterWW | reads selected & desired warm water temp | +| Boiler (0x08) | 0x14 | UBATotalUptimeMessage | | +| Boiler (0x08) | 0x15 | UBAMaintenanceSettingsMessage | | +| Boiler (0x08) | 0x16 | UBAParametersMessage | | +| Thermostat (0x17) | 0xA8 | RC20Set | sets operating modes for an RC20 | +| Thermostat (0x10) | 0xA7 | RC30Set | sets operating modes for an RC30 | +| Thermostat | 0x02 | Version | reads Version major/minor | +| Thermostat | 0x91, 0x41, 0x0A | Status Message | read monitor values | In `boiler.ino` you can make calls to automatically send these read commands. See the function *regularUpdates()* -#### Supporting other Thermostats types +### Supported Thermostats -The code is originally designed for a Moduline300 (RC30) thermostat. +Modify `EMS_ID_THERMOSTAT` in `myconfig.h` to the thermostat type you want to support. -To adjust for a RC35 first change `EMS_ID_THERMOSTAT` in `ems.cpp`. A RC35 thermostat has 4 heating circuits and to read the values use different Monitor type IDs (e.g. 0x3E, 0x48, etc). The mode (0=night, 1=day, 2=holiday) is the first byte of the telegram and the temperature is the value of the 2nd byte divided by 2. Then to set temperature values use the Working Mode with type IDs (0x3D, 0x47,0x51 and 0x5B) respectively. Set the offset (byte 4 of the header) to determine which temperature you're changing; 1 for night, 2 for day and 3 for holiday. The data value is the desired temperature multiplied by 2 as a single byte. +#### RC20 (Moduline 300) -There is limited support for an Nefit Easy TC100) thermostat. The current room temperature and setpoint temperature can be read. What still needs fixing is -- reading the mode (manual vs clock) -- setting the temperature +Read and write of setpoint temp and mode supported. + +#### RC30 (Moduline 400) + +Read and write of setpoint temp and mode supported. + +Type's 3F, 49, 53, 5D are identical. So are 4B, 55, 5F and mostly zero's. Types 40, 4A, 54 and 5E are also the same. + +#### RC35 + +***not implemented yet***! + +An RC35 thermostat can support up to 4 heating circuits each controlled with their own Monitor and Working Mode IDs. + +Fetching the thermostats setpoint temp us by requesting 0x3E and looking at the 3rd byte in the data telegram (data[2]) and dividing by 2. +The mode is on type 0x47 (or 0x3D) and the 8th byte (data[7]). 0=off, 1=on, 2=auto + +#### TC100/TC200 (Nefit Easy) + +There is limited support for an Nefit Easy TC100/TC200 type thermostat. The current room temperature and setpoint temperature can be read. What I'm still figuring out is how to read the mode and set the temperature values without sending http post commands to their web server. ### Customizing The Code @@ -286,7 +311,7 @@ PlatformIO is my preferred way. The code uses a modified version [ESPHelper](htt **On Windows:** - Download [Git](https://git-scm.com/download/win) (install using the default settings) -- Download and install [Visual Studio Code](https://code.visualstudio.com/docs/?dv=win) (VSC) +- Download and install [Visual Studio Code](https://code.visualstudio.com/docs/?dv=win) (VSC). It's like 40MB so don't confuse with the commercial Microsoft Visual Studio. - Restart the PC (if using Windows) to apply the new PATH settings. It should now detect Git - Install these VSC extensions: PlatformIO IDE & GitLens, and then click reload to activate them - Git clone this repo, eith using `git clone` from PlatformIO's terminal or the Git GUI interface @@ -295,6 +320,8 @@ PlatformIO is my preferred way. The code uses a modified version [ESPHelper](htt **On Linux (e.g. Ubuntu under Windows10):** - make sure Python 2.7 is installed +- make sure you have a Linux distro installed (https://docs.microsoft.com/en-us/windows/wsl/install-win10) +- Do: ```python % pip install -U platformio % sudo platformio upgrade @@ -322,19 +349,17 @@ PlatformIO is my preferred way. The code uses a modified version [ESPHelper](htt 7. Build the web interface: `node node_modules/gulp/bin/gulp.js`. This will create a compressed `code/espurna/static/index.html.gz.h`. If you get warnings about lf during the building edit `gulpfile.js` and change the line `'failOnError': true` to `false` as a temporary workaround. 8. Modify the platformio.ini file making sure you add `-DUSE_CUSTOM_H -DUSE_EXTRA` to the `debug_flags` 9. Copy the following files from EMS-ESP-Boiler repo to where you installed ESPurna - ```c espurna/index.html -> code/html/index.html espurna/custom.h -> code/config/custom.h espurna/boiler-espurna.ino -> code/espurna/boiler-espurna.ino ems*.* -> code/espurna/ ``` - 10. Now build and upload as you usually would with PlatformIO (or ctrl-arl-t and choose the right build). Look at my version of platformio.ini as an example. -11. When the firmware loads, use a wifi connected pc/mobile to connect to the Access Point called ESPURNA_XXXXXX. Use 'fibonacci' as the password. Navigate to `http://192.168.4.1` from a browser, set a new username and password when prompted, log off the wifi and reconnect to the AP using these new credentials. Again go to 192.168.4.1 -12. In the ADMIN page enable Telnet and SAVE -13. In the WIFI page add your home wifi details, click SAVE and reboot, and go to the new IP -14. Configure MQTT +11. When the firmware loads, use a wifi connected pc/mobile to connect to the Access Point called ESPURNA_XXXXXX. Use 'fibonacci' as the password. Navigate to `http://192.168.4.1` from a browser, set a new username and password when prompted, log off the wifi and reconnect to the AP using these new credentials. Again go to 192.168.4.1 +12. In the ADMIN page enable Telnet and SAVE +13. In the WIFI page add your home wifi details, click SAVE and reboot, and go to the new IP +14. Configure MQTT The Telnet functions are `BOILER.READ`, `BOILER.INFO` and a few others for reference. `HELP` will list them. Add your own functions to expand the functionality by calling the EMS* functions as in the examples. @@ -378,18 +403,24 @@ Porting to the Arduino IDE can be a little tricky but it is possible. - Put all the files in a single sketch folder (`ESPHelper.*, boiler.ino, ems.*, emsuart.*`) - cross your fingers and hit CTRL-R to compile... -## Known Issues and ToDo's +## Known Issues Some annoying issues that need fixing: - Very infrequently an EMS write command is not sent, probably due to a collision somewhere in the UART between an incoming Rx and a Poll. The retries in the code fix this for now. -- I've seen a few duplicate telegrams being processed. Again, it's harmless and not a big issue, but still a bug. -Here's my top things I'm still working on: +## Wish List -- Complete the ESP32 version. It's surprisingly a lot easier doing the UART handling on an ESP32 with the ESP-IDF SDK. I have a first version beta that is working. -- Find a better way to control the 3-way valve to switch the warm water off quickly rather than deactivating the warm water heater each time. There is an unsupported call to do this, but I think its too risky and experimental. I'd love to get my hands on an Nefit Easy, sniff the packets being sent and reverse engineer the logic. Anyone help? +- Measure amount of gas in m3 per day for the hot water vs the central heating, and convert this into cost in Home Assistant +- Support changing temps on an Nefit Easy. To do this you must send XMPP messages directly to the thermostat. See this project: https://github.com/robertklep/nefit-easy-core +- Store custom params like wifi credentials, mqtt, thermostat type on ESP8266 using SPIFFS +- Automatic detection of thermostat type +- Add support for a temperature sensor on the circuit (DS18B20) ## Your Comments and Feedback -Any comments, suggestions or code changes are very welcome. Please post a GitHub issue. +Any comments, suggestions or code contributions are very welcome. Please post a GitHub issue. + +## DISCLAIMER + +This code and libraries were developed from information gathered on the internet and many hours of reverse engineering the communications between the EMS bus and thermostats. It is **not** based on any official documentation or supported libraries from Buderus/Junkers/Nefit (and associated companies) and therefore there are no guarantees whatsoever regarding the safety of your devices and/or their settings, or the accuracy of the information provided. \ No newline at end of file diff --git a/doc/home_assistant/automations.yaml b/doc/home_assistant/automations.yaml index 7207f0391..afa295b75 100644 --- a/doc/home_assistant/automations.yaml +++ b/doc/home_assistant/automations.yaml @@ -39,27 +39,5 @@ payload: > {{ now().strftime("%H:%M:%S %-d/%b/%Y") }} -# Boiler warm water temp -- id: boiler_wwtemp - trigger: - platform: state - entity_id: input_number.boiler_wwtemp - action: - service: mqtt.publish - data_template: - topic: 'home/boiler/boiler_wwtemp' - payload: > - {{ states.input_number.boiler_wwtemp.state }} - -# See if wwTemp has changed in recent mqtt payload, then adjust input_number -- id: boiler_wwtemp_incoming - trigger: - platform: state - entity_id: sensor.warm_water_selected_temperature - action: - service: input_number.set_value - data_template: - entity_id: input_number.boiler_wwtemp - value: '{{ states.sensor.warm_water_selected_temperature.state }}' diff --git a/doc/home_assistant/input_number.yaml b/doc/home_assistant/input_number.yaml deleted file mode 100644 index 37cbb1f2f..000000000 --- a/doc/home_assistant/input_number.yaml +++ /dev/null @@ -1,9 +0,0 @@ - boiler_wwtemp: - name: Warm Water temp - icon: mdi:temperature-celsius - min: 30 - max: 60 - step: 1 - unit_of_measurement: "°C" - mode: slider - diff --git a/doc/home_assistant/ui-lovelace.yaml b/doc/home_assistant/ui-lovelace.yaml index f4d799fbd..e144c7344 100644 --- a/doc/home_assistant/ui-lovelace.yaml +++ b/doc/home_assistant/ui-lovelace.yaml @@ -5,12 +5,12 @@ views: cards: - type: entities title: Boiler - show_header_toggle: true + show_header_toggle: false entities: - sensor.boiler_boottime - sensor.boiler_updated - type: divider - - input_number.boiler_wwtemp + - sensor.warm_water_selected_temperature - sensor.warm_water_current_temperature - sensor.warm_water_activated - sensor.warm_water_3way_valve @@ -31,7 +31,7 @@ views: cards: - type: entities title: Shower Monitor - show_header_toggle: true + show_header_toggle: false entities: - switch.shower_timer - switch.long_shower_alert diff --git a/doc/telnet/telnet_example.jpg b/doc/telnet/telnet_example.jpg index afaaf4a5da3c0ef9a17204da593acca3d5a72dd2..adfb76f1e0f734cb7016455b40954f07d471080b 100644 GIT binary patch literal 73952 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 Du#Sp? literal 70914 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%0A1^#I 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%0An;`>Y zD(+db$8R?AL@PrhG-OBA8=L89WuCQOzyJFBBbM%IG34F=ez69`(}Q5o(}C#S{nxQ4 zJxk`g%d-P{`3(xlml}9at)faYR@0iHl}|(Uk>>XKB;pd0Aj96-_j?|lzagfL4fdHT z`(^kh)Q%g@59Q`Oo+M#;7!9Ln$k4T_F)EL+;_y}5iYZMEt+^c3L^gUNfr63pB31yz z-E&C1FW>1PM>CJDOJ{Kwu!ow~iASt%yeU&S_Tc*O3<7G&k?C196Oc0DGXBzAzbMW=>`NksWu76z6yIwBh z8o5*ZkEh6~#Gw3lbaTvpY3_Pf>zJ2|X2g$IZymDXA54-uVy79m()EGkyBqAaN8M z(#GfPL))`cj5}!D^UmQ!>RtovF8_j@w;WyM&3run$25N^P<^2*G=)1AJ<5HwhNBv@ zYi0yc{Hz~EE<9HO{cqqT$8jX#`fb>6`mIVY9vij*1Rp|#JP$J54t#|`eWK$Kw7&rXc99={zvvEuW1QrSNKtg0O%dh{hqxqE5VReT_iLQ$g`;RRrw z0*<#^kN-Y@=(ct3Z@8nE$+~kl*W(n{T|su2d1xjaB4_8l*04S9+GK zu4G{28?Xk8L+6RqyEwWxVq~>zBgDIG0I%mL{iQUmpl1bWoy%b=One_;r{JC#tr(0n z1V(LXvTmq$MOA8^LHjt`ad@wBGs|91XzWd%+T>ncH|-H)(TdU?-1JeB*u;Co82bJ8!Iw5CcpZArtYhm#rob*p;!VQ9M29i}~SN zNdHM!TW78!>Y>&36}m|Qdcol2rtUC^(w1`{N+Q9^h%H);?jTaW5{)|S@0!8M21l8} zXhecID?Y*FAj@+~ZL$80m zatU-L7wp`qJ@M|k$yKIE;Fa)TypcY`xiBQRTLCbI=lvs7_*6iiJ6;$A-xr=}iDwzG zK4dS)dG$CkyN9Qa!9&#Yp|Xa|mxTFLW)%gIv8EyH%W)@GfHSv}%0U^+)(fl^&$I&@ zZbGSy2LOKgX{pR-zA6w0UNa4TZ)mtSni17c)4HC^pj=)`=2SdBrj(UGeK$OBq)tnf2uUPf);A@lRKDyBe;zK1h>?m6WrSSdyD~I z8~N#)iMSVNK4fk}{c zdsmOMm0-uG%+P+Acv793$}H{7B+XZfO;|qo#(cPxM(2oH zl3x9GXwjq>8N3;*R$UBYjh>NnNyK(5KlLExux%DPJhP)aTzTH6-nPz7I~AYHV(3v*2sf>F5%R(wcH9Tz zN3Zk1;XscB+PL^mS5ZXD_BeNO4z2By3Nedf7r7nqbCDMUv5w3&7{N}!4QG3VXf!(! zrdAfuAM_uG*|#0KO|a=@X1+GbwyoeSL~ACPiX z?+uD95b-Ivo&#S zcu(=5%=z-hP=OW1-SS6!1WC<{AgNKLiDWDgYOvPPV;2`;@;QksjPojZ;AKZ9qwzl! zQS{~=m2?>YcK)>4x%}$y}e8ZN1BRLC&g#lv@j>DQu8YiJB;ejk_-E}p+-Kw6~2 zL&f##@tUu|=*g&K_PLVz?Q&V z{$iWytbkS?u$uysPU8b|Ly_YHzZiS5{WWcB^>H+~5I;@p2{ts04^h}NFGX9#5cK1{f+dY@za0Gx!n4UX5HSw#9-n?`9dco` zE5=xU{~G>f$}pbmcE1DZ;zeYoPd$D873tDuy;LKXN~D|CSdpiA=}7v}Az^YrE% z^1j;(W(f8INNycqFQECey})Jbx4xhuxSB?BaU84KgYq8H-cUUi6Y5%%lJd)1iZ82< z)PboBR6__^+~{V!=OMj;KDke=?~`Tg1TuC zT$7>0zh7*RP2fvzqj~|uXx@e&o3T_Ydasiw>o)UAT|#V+H+}yq?pzt($u5c28AXC| zjK5zRqV>zCN!pV^!>5QA|2aVfi<`&9@G7<5=g)1iLCyMxRV?AMrM7!zh9ZYKF&7zW zypNr?F&tM0TIt4qYo+V|+g7?Axhm~fW8;&z_w$`yIPvByOc7e#Ht(kNEw^nW)=`Pq zXCGgm@G62CU?J3d_`X4<8?Yk3gb^ts6U&jztsD|IQ zr}V$IGs;OnDh6JW(f@&&-a4H4-euixQhmFAu&{eO0ejsr5tT;kt}vheR((rsBc$r! zIR;Ie>f<)ai&gY0-?iu~`XQxaZvzv|kL?Cwf_b7bH~wfLbp2Uw+HHJLt|f@B2O$aJ zc^fUBARH@;oQZ)xzZG+qgv;5~+^;(S_?rx-iGKdutVB6=Z4Mw-d{7Qv`)04bn-G}a zRaJ}c)rw*_G`SQ$a;!7JC$QPyG31>AS>R@_mcNvap$%YRym0oCEAy_>cbkod!auOt zT-tzr+D#e3kwXa6q>s#BPm=@MK;zFZ&UfIp_NVh5jP0m9-Jk+IGlh=|ql9Yj$~;ac z?3!ZgX&Sqka+v;54%DmP0Upo>8S1fY=4GSk_-Z<6JgSR(HVvIryH}ST^@m6+HiL@r z*_%u&jQ1qSoFkT*IXqA!!X>RB?p}Q(-074IDsNO-m|eTGqhLQSeQ=<{a&w@CUC<>i z0LMPb?t>yqvliFTjU`nrgG#IQ*c1)31Y}+Ey+-@WipvU9KRJEgTg<9xR^E{b=2w7R}b1ipAhW-!O@md=^)QcV+NSS-gK3xYFJoI{*|( z&aV!pX@1?8##Ej~ZU^kg3au^7JG4ou(F|HeT*+b=G%f!GkA~nlsn%yre2AW(G(Ye2 z3JnZ_8hAQ8OeH1aX1rpr{pb_r2qzG);T5-+IfIV!-Kxw5!~jWUli)2;A4F$Xu^NX? z0wJ*PA^2JCt|jkZU{Vzl_hC36pCUKudxe#SB38{!X(NVKT9FKn9Pf)Sl!fs|oEH%g zzdm$Nky307A?~T)3;H7(Bl?$UjNb3$wd5cFIop4^w_@vPe3xaE_PPnPYy_i-r%^c` z4tDK+(jRm=te-`uI@!X}@xuy0meDsNVUCE$bkJMx!hG#V`I`#$rE{d#92l72(iGPR zGe#*#S{?6@kJ0qGK(%0W5@@MAP@51KI}bdCYe|Cu^N+({n13=o$UpXN8ovNZ0XbmB zcRzB#|7qj;Qx5pU=f19?7To1s-tDr9&z8$Zo0tQLj`6GVQ;|T_fngB;d`(hVzt+*z z$Wy7T?0HR-`PCMt(vapI&4-IDfVhrCt@ibHo72EzcSffHa-G0 z5B{)e6KNk(Lk;iy*wT)VS0VCAU*NL*^9sX0N5wr zmvDdc$NuO<4eb1Hv^dlM=mvMQvG%A|&5EZCI=K+ppuoTmVTKJEFO4~lqG9i1D{%Eo z7U#7LY5)7^(CXOd-c7_mnIm;B|D7E;}BS4OiM$<&Wr zk2XY3Pp38Jh@C{y8zq&El%5Nk|PNLu7j&4@pJ?5h4ua||&KUEG zt4hAQs0MB7ixim;AD6s4t1SLCVBYTAVNV)gp4jF#@RXol1OUq(SpOw>yxLa;6^*e0 z{MDG-cRT486dPkw4M}+-lqjZT*ztiVTn#JXhg2o{Qb_`Pgrkqgu?mo_$bwmdLI+m=wPC_{7tHE0SqC-cpDM+kwuy!Q{h#60UV8|FA+R;O&I5?-S zYH)udNxU*V3gu{}NC6yB#^FuJJS`r{fDfr+0bLI08Lg}Q(Rc{^6JLF|zte~uA2qq` zGy$Cf!i-z0q+0oX`DzqI`q39%R88|u4OhWcz@;DH?I0eAzM)L~t1ec3@XK%SqLxjAJ*4g%>;%xhX=Vxf zXw!!1ewLq8`-SqluZ4bBzk&t>m$E&K7i%I??4|Cd4Evh8}brRq&SAU z-N-MxD9MkABMQDh)bK^6ukQ5t+xEV7Q+fya2!({EDX5%6ab;-oQ_?Kp9$BFT zl;LKvR@@27zG^k^-Wcq+4RZgkG41_cV^aB<#$3H`l;{Rnz4rs-W~8v?y`QK zch<=q9UcV=$VJJS`G4#*lf80zf+@DSf(mJ2Cm>2?ae+M zb)aU@vdkevBi@TNUP)IOvW;E$ zYa@hACpr2DK!7V$kVX zjx4|cqx=uI2LE)%BYYk(=Mg#;HUH6yA$&7WzxVJ8yyQy>`YN_K-if?8#x8wC$#2$7vi4EbhkgpkDXA~AVB#>6(27{2a(xrz7g!*agR6)_geC+K4RrjqKu zy4U@88Vzq*2PedTloT@*B5r)2>tpaI99-8h_uW62zidcU59iQ4EW=CWhMcoxW{bjP zEh2j^^dNN}#|=A7fzJ^Pe%S>_Lr*|~7jF*m-Q=u5hUom`EM*k|nt%HSeh z)Pod-G;0zFZ&cC(=wd+$+fyg&^{c~RsrSRy;bB>&rc8|~ZUbA6dRcMAhV&)>9{$rJ zMwcB1Qlkes%!OWE(&HUFbBwiFxk|>xKJVFYSaQVo8B>+FFaZuoYnbhZSHhwM#eiV0 zJac5OECMm?YaLb*?g_$vIluGlcmDT1p*tc(jl;e&V?8%*QdLb8z#Iy#%(mxusVgrf zto0&N!W6gyUq-Zu$cf0pCxg^#%j2?==A{frJ7OOvCx;Fye9PPcb9*Z>5eY_^xSP1( z>dac{o;<2_vTf)>7ac@9sFK#8S+H5g@XDsHRiWHSgYIOUY($jl+5ELW=`3Qu)Jkd& zc97%6XA&0_AKAnZC;h^1_}>?k-`fpyLLzrUOOY4i;-R)8}A=9x{o7lm-U8{bK0Hh)Dj z6B{^elS=i$RFdO0^H9dq^G=c4;9RRUcwEcfd@r3H2y3rrZMU)CxsZ5i+UqQDxUM|! ztazHULz?KOy9N-pxunweIsbd`irM;40^fR<>nFaqf?xaI5UVBMlDGH#i+z88x(fq6 z9p72Ne-23Tjc9)b`s)KG4>bO;i1|qhiy#>hOhYrphm81yC3`g=bxVQ|M|Ps+Hy@5f z&}_v$^FniKBR^>1zd>}WIf%*$5`+ZEFn5+PWDB~holWufUuXY0$HO2li>IjkUH713 zr=Do;34E5I2@~{N?DoG0>R8YgEwcLw^d{_~hgn%=?26{DmoUxcQXa<_l%kVw z!`vNtodT>N2~$IqUn*JP5p_Zv%bb$*Dt!J!B(UX#($8?ERjnc7H!9Dx6V~4COy$u6 z$VQ&Y5L&o9)b5@XWB23X*(QfcS*hNfZIRp54T9Auv-jtSUd9dPIrJ2@sGWybd>Iq% zlDVBU42M-wTMtYe56OLTjOb-?#UkD~|kCbl(CdmxXn(PW1urqQ|N> zv-YI3_!%5jSXD>D=x%jLYkJ#$INL5bh-l*u8*aggBglbpkvZ(-(8yeLq!+tN=gr`; zy$3y_G)^f#p|4)YVfOc)HkGC_IZKDFVvX;3Lgszo8gPEWmqWsIbf?@fIK9e3cgHNt)nSsJ?jpup+Iq*M88!=f#BCAX1TD zPh})Uu?>lXO|v{XIUmFipS&O9j!L?1^?~d~7ySk5<4j^2>NugaEGsF+NGH2l9ji;+ zj`w%0(dtbcq4hUf%-+Ypk3-q&^xOwU0NbsX|F=m%Y=!9vCY(iJu8iTN;ngM~(HQAH zPh0!$i@&K9%6koMPnzE--pu38)9!H>g{M`mQSwhhWDtta!~AXZg|96{A-ij(~@wQS55#)?z=bTzP+ppayOn^mm5&NsUy{b zCHq5~l1l=bypqj>WoE6zzjS=ctU;>D#|2jdvkx_alOTpfbYU4eUjST_d-Mj>~ zRfM|xJR-v&YD|VQwy?B#rg8SzRxUUWxgqz`oeK^iy8&?Z4`g>5~dpWbz(GqjjReOb>08&FAROJo^G~r~!D3hWnTBZzG3teXKGT zsfLophe~_}pf=wkSiYOXhCzQ4Qs#lOvE z7LQi+BLgy9_lqY_!&E z%+A>S?^=1Z!oCK4R-@0y-<;H6rb8 z8Tm7T>_CMR$>-0x^jFST9!=aQ5JwZY9WZe_75;MKo_A-(H+w9N`)o&B1ls2iS3A}_ z;d0+OWGXy`J&~Z6t%`2_(S#H4v)ggh7grx_vSwz{WtA`<+PHz$7!zM@?2UsP1NO2ta;5uaJxdSX7U2lRJuF6;Pg?)TMd8%{R*LLlMG{84oyx zj-4Rcpro5OV#EqYeZW^Jpn6SS=6ZB-JHPG=tOjEUDmzp~ijY;+>Ys(-Z4GuBYCyvM z2)cB!Z69Q;f_&u@5tf{*3t;Cv)Fg(DH>)n#E?o{zNH1QY@xjH&#pxo7FE8{i& zZs1$g`!yBrFaS{Nc~tM93!<2FWaIWx!6x@hH02Di)CPgtw+o^E*l9DFv_@Tu@%Nw3 z&lG^DA>EbL>j|THUp&{%akpy_DfU39zzVyIDMPeH)DNaepk}+olZPuYOWOMEVh@aU z0aD!mBHHh#00gxzLO~~ip!R*i(7pKBzKmWqa3c7x0Ii!2 znYV5Fe>Pod{n-Kf7Y-cD$nuTV$Hw5oS+&!6(0YAza-UJxA6jpY z?WhsQ@dTG-fG1hGZhy7UNGguh>2dF#1`BxYCkJtQeKTocDG>=gC0+c1Xs%qYo^r|K z6&uf|?(iDhCbr5a0s^oiLGei^DdRe$h~dD=(-%@Vpn^I00moj*=MI_*FToXa zf61vbozZvZzE@O^T_>w}@sus;0-N)RtmQOmdK1_1xYMU395@`l-ub^g2(jf6Fo;H1 z{)j;&)EjTNva}+We28X#u&10=|EHt}Won{!(lB1G-smb8EQG|(WvtMP0ra$1+-VLbZxREn@{;X+r1IF- zzcn12M zHPhMX)eYmIu`AfE(6sqJr!8|qF{J%85oLQ;pagij-yr$=ZJVStfxv z<%dRD zd3@2E4M^y>AB+dXhxSy%m)W7D0*PYbyuz1WnlJONl+kTUROKibVB=pRPC2Jpmm__A zxuNP-C~3A7vovy-1z^hKkQ=fAtO{hA65QA9&jRrX8B@%v*Xqh4?fxC`sJ22;&!+FP zItx_4R$RQ~c?96 za^3R2-a_rGZ%cF(oY&STx6XBlmaERAf*7%f7zE2oHQasMG_9Hip?5qzYcYN$r8d3m z1)PV74A<}9@te(dw3dLoA^6e$3wPtnP`xT#Yed~?AL2`eS6}@iK5l90K;p0nc7rJU zl@tZRsMThb5j7|Ufvetf_T+=P0hr1<<$XCQR{ur`?)K*pAMS`PL6_5|!E|u8wMW$J zA9lu?6aW->>DD zKbe+5E&?R;_KklX3)YLQ@$fRHn8L=TL7_5IaY+v8R#}`yVYNKIbujyf*+DOK+JI{#9tt%A?A-UM@GOj+J|O9m=|Nm zJ@+|M>n4X;rAVQ2N`jQb9E#qi+07-~)8CAjS{2w5#U-nwlXLrHO7^K?Np46G|CecX z*WY8~Iaw9EzGLIV0c^bAudwkg_W$`|cUQZlAd9dIEsoDNr;BygNbe}ub3q*fR zcudCvyUWxR>cxbq7xaq5ldU41K+xc@FeNj}h^%h{VNB8ah}JHsc;|fLkD8n z>$2SF)jZ2tef&7O5d5-jYY@lNI4-rq&r8E5C`l%@OKbEBB{KrVl6jYcG`@J8%+1x< zcF7A8(i@@%tyvX!5pi@FfUn=)kh6HK(HW8xa=FftjT~>s7a$s>p=?gjR9JnHc{hCt<{kzU2SI7g-tvPLG55@=IuAl=vGiFZ+nkxCyt0$8c;fkuI@`pR8CfxBnEv?(3$j z9KK?9ZA8wyl+7G7L^);ivTsVXU~RuGsKwoo)q?IAzesabbQ#sN$~oGxOL2=w!d)_= z{|N{9UWG1n8sdRC1!$K5@X!_ri^#-8L`WxV4drK1CcLGmlU^Y*L(b8Yj@{%tZeEFB z>`mfaVOPyHJFq?G7!YcdS{(AGb0Ndb_e0D5khmqiCpbih?AZN}WX#kbX>TOhFsa=K z{v`>d&3!{vfE^h~+&g1^*!}{h`(37{`M)?YgdqT83MhlDg6uK?4zYvDIxxdzv5wy>#>lid@DbjO1)&nf)JDmsd+YQUsyk(t1n+0n_Hg>L znz(zqJQn^!v=17^@2S)I(2;KhIwu0obe$jLDb!p;(h}?+cQT47N(mYCwN~Z%@ z+VRMg=^j@0z&wRS?5urA=v(2LPWpmrPqi7dgfG2{!yaay- zcUHhk=Sb(B?vXrsvpm?*Uev%RUeRacRL47cy(@Uw-U5F1aKG1Qhwx2NHlByVP0W&R59q*ohI| z8z;@GDX;yh=FB!i$KHVyrV$ z@jaGOdswKyWSP}Gfyc4;Q12dNPxu#%J+8RyA!)~mjc9?D_!)>YKz+KX0l-DsWN;qA z=!FBlUghY2HAt)guqQzqrymazbPI}Kh;E9paqCg{1R*N10yUI8J4ef?Zg+CfKNW6? z6Md1p=lxq-R20wWpPP^2v-Km7O(hk#&IXnW@cF7ANbVt1Kbs;x`%UF1xhyqXqNJ(@ z93a@JOpGF>R7<;Mkn7?^i{DL$7sLlOF0L@3j`X`eq14j>ua+b~Jj4vr4_(CQyf3<3 z>0hDVS^m3?%@J(|0c>n;zp$|Zgtr(61^!4d#XpRjjF+_n4;(ixA2g)8_vzkC)Gbtn@@;^yK$>033}nVl-QTkRv%7_FP@wSGO#)kG@)rIZC8+ zSsc2~C|gmSU;I2G*Or0yMpj=gAW-IsA4#RQ|Bx_*!G0o6NW??qn<0ymhpjw zz~zo-nzjyUK~C>_M;ObQ6AO^r08ry*=y&Ug{J&pEsG9L}jLk=Nx7mtT(nkq*Kg`Yx zAMDo43tz=d<38W_I1$G`TO%z=N<W*{W0pF!1&oe?k|I20*gu=tNO;5$583cIpn1J@Oh&cVE|qZ@#h7JKryz zYy^xB#)rkCtkBUOW*9{lm`ZT97|7?{)Ezl9{;c;t(Wf*G1jaaxj|=e7X$%zk>YW|P z9cI0;c)XxE^vs0+7>BiL6#GX`qTvhk`##wFy4Z~#UX3P)>lU{=5zT}il;DS9dH`rp zj+V|OqiIJYRj|#t{3lM52324ECcPRPy`v}G(6fDB+zcSsgsoeWwB?+juz9)vN2!h< zB|!dFqL%`hYQn}Sp*p+?G*4iPr5-~HW#xr5A6G4T&MdwD5NMuny1YFJKSFPmd3M|% z0hCEByuX8+n)8*QX0nuov#&Pkkf`J;l^!J$e--FzyF7Jop$CzPe>9>v#|C6H!~1F( zMFP=4B_tWHtbaNaN?W~(S(G#4${U*K=c(40zQk}kGvO0RIipb)4?CSc%mS_{ zU>pyNpIWuKTEn++pbBjDryH+cD!sqgXvP-uxFgG3WE#l% zNbU1pes8z^8s$K_-gP%KPu+_dgrELC2B+yWMeAsL>-lx(-UwiM%m^4hR& zz`<#ndq5@|=9{`5E6slNPz28j^UcJjcp|%CG{ol}cJ0}6Gmkn>B!J47OSBO!sW3nC z*62VS&&epOFjJn3qjDu6Z5qJ}_8=`ty<*ZE%@^p z^g)*1qPCG-rrV`TKFO{2a>m7;GK*nf0g|C()D53b;la1n3|?O16tX+{?4)NQC*z{* za{QXE{f@r&p%$LlKOD68ieG;@Xj_zPZIq*<{a}^>#PHKl*a;()ocnSSe*#X%H@qxO zou<#{6U)0$q;vF>OlrL>606fUhYi9r!r0g4fZ7St4Z(r0L{=08YvLoSR?Thib1=dg z(<9@H-_hK#{SYdzrP^Cv)H{>{BNy6Ut)x@wTGV@r5gQM3@m*A5ewHw&NH zJ|!2{k|TgvRxoLoeMWaH0;F;%1Nu>LS`7IS{KEb^D`zL4kfg$Jm`vLW`1oh-5Q9>x zlXQy!5~sJ^_Wi&!-%*vxb}a^0TAZgJ5^XkEl4P4BT?iqc>oayp;Hu5k3kK)uk|&!n zHL~i5vfe|#3aTL1FPPYYb0$oA{?4T5dNl(_G^!sUgu}JR=p9G4W`$`!=(K&=9yFEsl4FOrwFIKbry!)Dfni+EyNE92x;5zssKz=+saTkPa+_CrSM($JwTX|B2|x_17& ze{x}Rtz$>VYCY<}H_%nV-?YxNK4r$hcQ+z`SXln8lo=w?2`GFHw1bjl(9xuW_}DtF zvS;sdJC}X#*x1-vj&a;FXV!DE;Pi7>CFUH(rc_&W&(MKXk+Y2{RSz#(Krv7UKZU#S zK}GHQ(0RMfn60s?9+?%|$?}6+hR~QTsrKbL#p+_Eav%N!lUV;4$%Y~<&bFZ(#d&NW z(VOfnPqt#yhAU)2H=29JA(_MQ8W~4vY-UXx-lk&XVMr=ILe8`HLc|z0DuJutB7iE? zH_gMYN9RLYYar|Fy6Me2B3D}}vY8=r%T|h-M;Jd^0r)kh8#(=nAy(Eb1^FGPeYBo2 z>ocdZJD06_f1YIFw+s9RA7%sK!vLK2gX3TP&M1UCRuljVn>g=#EFu2Rc4tJkTWHSU zMyQSMX&r&>eLx-S`olN0FXe$K3pekP%gt8hha{|iJ0{@Le|1dY17gZ_jV1p#F=e$% zpbAuNO}7SQ+IWgzkL}{ucvFEJK%REsf_Xu%4Y3!RoR%!Yz8JS)gerVs)_uHpYQ40|F9`J@A(Powo#b z*o_|M0q5aY-3g6t>*L-4g3jh!rF_xQOdtKWlRug%^CEeLQ|-I8#mCpPqnxHj609Lq zaB!xP6qVCah$1F}h3aDp5((n)pHa#8*10+WdwAh`&+MQhDx3bccScD(H&Kvr8B3X@ zc(5Zs{F#iS1-7nkgEas)|EgIbNHC60(dQVCZn|@w7?tpvz;`+rB#m%CJYWMaZ>?d77H(N!q zc+_fVx;mW;tKOgsi?@AH!`U_Js5>$zg{cPa4LOB$(fwI zEPy-@Yq+xJ2HTP|#e$8K`V-0qiZ3tCRRs(*Oa-i%e!f!i_B_$Z)A5gNQ3Cn$6HVNXaHK>(}G8(rruPH1RUp%ItxW6Kkn&FbOjpI(y|?ZId#+=~*2=B%nnU*(GT_~Z*cM)kzFz-)gsge9=VmgA0pp_o zo`LM#KQWLk$w5vocj2oud%ysiDIwRMfVfin%?rXb%*3AwmOD$-8oB zo^uKL1jYF*PQGz_9POB{g>-;yJb=Z5R5w;KHLzMUSU4??r2+3&g@U<9;k6ObX~wSe zm~m`h5hSPJv!S3=C^Oj*>!Ho6bUUP%@I$18bh63K^T@CZx9nQ^Ys%B@NqD{xov5*w zn%9P4N-n z1|=-%sX!boW<2_^N4eFo6vM}Nw!k;>Bmi6(eY-Oww_?Mu`Q6>6>*}YwS7X*?TQ6TG z87W%S*UiH6scK_HhDv}}Z&XY`qJAcKofcOyaK&*U-Kej1%cEVf7ug}TyRo@T*w|?A zZfv5p+Dh*m8$Q{@e54Nt_%I+WkbX#cZkBOnAW%+rlRO1zc}d;q0q#}4apwhcoU0?| zqV+bxuK^B+UBtHs5_GD3w{aq{*Eq5LW$GwO{i8GWmS!saPziR;Q^L_a#>Cz!o6Em) z;m1Jrh>q_MB3yfy0pJ&3jHMMA#m_2*`Q=319Rz z=M5&Dd`JtHzmYKs*=@fwKhAE)BG~X*e6w2zq&!PC6Y$WAP_rlSuYlmz9|3~zAzkk% z_c$GfU+ZAUoqqEuW*SUV%G}KP_M-9rqa#8Qu zBmfo4FGLiIzjY|X82=7r3RFXOcZHCDx+~$(Psn5dV3~CN` zt30#jZF72eci4Nh1+97QI7gA?z;8Tro`46CqeHONp(RyNC|fH@*_qp&YBF|QFBGZ0 z2Pcy?8FxwLkYHaIx#v3ljw#ccu5#3Yzu%XgwPt&ISnBXbD!OiqeehJL6YVtU6pl2^ zI9=-+Wg6HVp5^#lbDE}KVIl*at3WIp7`EA5xd{Uv@oHYjwLr=5ZAPE)r&b{)tk(<0Wz?*jQ`p7Y7ZR=qm>aHwJ~5&@)vn5_V-AMn?fL7Qpy zh`$%+yE>tg<@YUwbkvgouKsj*E9!=;Bn(|%+h|5##Y&syS)=thm_$VDx*wFKOOV_y zLi5$VVVYO>O{+yOQ;S}@qjb8XK+UZ@5VzxoF#}hf-kK`#GZjeZ%q~_+X%rIht8b`q zy|9nj;6f_J(S!9nR&85XgSt!YFsHL@2X`w-m4phC_SXthEQlGH49GEjqG;VXjKEQ2 z>+tUQ^L^LX=o{&KWujsvtE24anXbPSGG-t!4zdHvSRdXkLpJ1r)K-Nv26xj0Kn>-& zuQuVM?EKe{@(k58r*rM60_3`_sOaq&A7>>)z?sI`wy}-3vfT{=LmrfT2s$0UC7-_b z2^CY(|4h@9csP33Kts}xx2PIA`lx+HNnG<7S7fVK*iAM)K#2V^7Z74U>lzo9B*dvh zmSK72f>X_pJzHMAMfODhyI)2LH2|ryw+-xfqUoATPx8Fe^*E1G*g!o<<9XJJ^m*@) zz;{m|fy6pd0-#M$rmf+#3Gs;`Y~^2H>jWeI`KC zduvqAEO8&b%?<8MTbOOqdYWy#Mz5usP&4r2A>Rvr1Fr;63Pk|a5FF^%{3PeblKi6l z97h&!%hw}wH*hQg7W&S6AJSh>>XLx}(s$K~1)KSNxer38F=d_KBX-U4O=gUmt89-U zJp(t%r99Ki!!LR#4x1FgxCU~|hXWm%@`uLZDvm~VlI9T8K72UPlFCOAm3_deg~h_f z1MYM`7JluY1ZC*r4O1>`(v@}=n-_VJopP>t{HDvH(?vrbAW++~v&Rm-8EL=L@nc_` zX2u&_KW>^`PBwlz=$GCaICU9Q_Js(HA4}d6#j9fHr4CJ7_jFbzPm5}KglF@7;=QWj ztF|U{qrPqLBlp%|zxcvu{0!NAxGQHAr%RrxXym>{%9IBTf zJTWIsXI1q|UU8hy2;+*DoVHK$5eB>G@0W2EAk~~E9TbGZ%t$xO1*{(~b{o8@Hoz!| z>H#tDQ$^e0>Tp8+gwYVFpXmR%e&P_|3Z#htjdMF(2mf4?^WXW&Oqj?m?(I*U#xeXr=wVa0xz;w zLnqcfOL-tMI$n#Zq@*$FEThG9-=jwi0zag@Gk6)KTRU6<%RQ8XekppzpLl61baE zy?f~NpP&gMLeYo)w5!6$Gq*E=!qiV*y6InY3(qCKy^Omi*kh<2^z>=w z>cf+J$EqjRfkQKFR8IUxJv-;x)up=xo)@1Ew!x&H(SNA8y(sZS;fV+hIN*%Cvcv=xk7;Lo$Fz4LL3-?$~hSPgB=e;iEHMlHpVLBIDf9`x0!PqfU znU7$>D`)nx9g(&}y9f1YlpIO z4KB)stjy_zxj8X^8Ua(yY`7;DCG-tUt_DFA6Ylvuv*4w6@5g0Vwp;0)?>IY*^N05B z&~K^le9YQy=FI`5)?J&9k9FYncFF!Y!wHTv%Ad%&8$CIXSm>Hz`P6NyZp=~%u*$Qj5WC3xOVqX5=_NM2$pP2WI0$}BgGiphmh@CojX@~!s8)MtTF zc@5(p(?-cN=Ii9BV!q#~qOHHYkkB6Bs2KWp7ZSAP@%TLzU51q(f0OZ5npN_~X#VM? zCda~p0JhS;wTQrH&5pD{*`vk__GtL*mgKI7gCqy)h8dt+y6f>dqZ~+H!)GpRF6??Z zs12ra#@NHhOOJ*7=V0$(SOdwtgc>EORBXB0EhX>Nh^UQe3J}-Jl;$M+D#v1HXRV^`Ya4R@BFAo{)#?cY+3m7@ztnP%l*6dKT1K}73z8sKC? zBMlGN&!*B2JP#B?Vq{>5Tir)UM-Mlh1MX(zYI6&P7mA$;uea#Ra!+MjZ8d-cNeRAP zxchpO*cF362Us58BL4x}co;6fPX=4(Zh9Z577_oEGBl7wtImqz>fl3Qwz)(yc0yZ_ zvgNu`%kj<|QyL=WVe8SynWJ*mui^G9eyj6WPm7Y0y1Q}6jf-@|Kamx5gItFLzwOVN zs2lS&>>YE$sq**pz2*f%1wQylUUl%2Sm_t>VmIoz@P){(5Mm4r)3DNHquIP! zcsd~p_!Tbl4f_8lbr|9Uo*t8a?98&G`fZ#|VB!p8$nwT&1h+SD?#vlA1qL;ql0&DA zGpoIa;E^fJl=ZZ)pRgVRR(EsYVWn122}vBOBir51yueeM{rESQA6H2W3_p_tqL+FW zU~|{~tgpog;kQ~aPIs?oolUIm;%+Q|{9my2a9YlFe1N98G7}gv{y-9yId)IknDnduj=g!x4?(fWVr_FP#KA@ z<(N;)@{LuOC_lAuXK~5AowYb${!mAa={#j~lGoh4FqiU@9VP6PcG1MV=TcabCViL6 zCf^EAiX7LUk;xZf|2;BU`;W+E|J6StldB*2r|dimFq;CTA6tUBYrT`@N22$FD5M%` zRkd0&J;}5sx}s5Ym&2Q1E7)`83(7uIA>6Z3YW_JPDBL{#vxH!*mQhKsm}u|%O;%$Y zb$xWXN5LR3Hpv$*V`z7uGTP&TD`t8SBS)jioV|YIL1l=kAVYs+mp-@mNSQHJ(d4QV zIc_MRlspkkadcoZT!Q`)xWJ)AXJ}4jDghS23nXuCky2*{i zOpEj>C#H6x&nI0S71b`jZt9p9to9;Nsc*XeRP>O`(NX`4^HLOWl~pl;s-$)}Py%t- zErEpoq*c-S%I#iDFwSk0ZP?k1S7;R5|5PsXGdVfa9Jx|qaW-lTn67+9OMB3`3BW#1}w7U{;M*?Ff#qQ$z6w^$kj2F2=ukNVG23}pvGW6?v zUAsTB@7BH+R@m%>&o%VkhK;)u z|A5gDQC_nonS1k=R!!L7Y>ObS>? zMiaIef9`p+8PA{q%js9!=4rs8vculLcN3EO^P7-6cmVh+Ui+QtGd(9{_-G6bBOPy?aqfPL=v9} zSCF<2->x8iYL5}FAmi7}TZHzvhrDbRYs{y8v&b%BNz3UBqU*ykeq&u%XHt^PM?A6n&^442@ zE)&AZ*rYlweot&&rBB?cAxGcAq!&UWySYh|BOUp>xyeC}-P~jwAveiWG6@b`RO06u z0=DaW1p;S6fgt%81pES-vb zxDil=`>Ohj8mQTHte!*lt}g+wG@#l5NQ{23Hn2YULX`P=+_W!p$mZ<`>w22~22({j zbtxoyizK2ditRUO1Yac5D-1|dUIDg!fkMEx&sFBK->s(vUfREgMidzhy=MwdFB=uX!!byM!*w-P^Y$hS9)x}UoC zZP)UiqXe&mqmt_cD0Bo7VUn#b9`9K-QXB5%X6(SxP|eqz-gSMTw(Y{H5dGY<%6T3N z9i!1)_Dgaj5WSsN``kqnw!?bZB%ib`(RX#`mypZu-J#?qeYHhTz4(d)xjJ%X2dGys zoKf$;O`0a)eql;I8&2QDjT7)(E|H?p)s;)hh)cN!li)@AaoV~^G+CLSa+yw$WwN^+ z@0RXXqC*w>g@}=72Ifv6fJu2c(K235U($-`#vSn(6z*K+F_T8A?d376ck`I2<3Jvh zI|>iRFz|y;9MvA9UY^dH_ggAZPwS`o#?)M#?L#ecGjCUY>{m} ziWoR548E5D*eKuhxgQ*pn*PAo6DkH?TT&DeONoO__Z^M7{5nk4G-IBKa`5awSWja9 zvLWsK6^_2rLxgHf^mnW^z*kK7b6@fLM1Zbw8I#UF)95=f*j6_fkY`KRt!UFS-jGQy zDJd;^8klrR{v*-20@L9XQiV@w)fW?~J-qPL%+Fz}9!(FYk<~qGj6v_$OG66+`TCdE zuB|tAruj_o<`Ei@yU$(}@a&Cnrn^Vpj%K;)5MqJG;6j{pD;Fxx zkca|BGU&<{)$5{_Sj0QLI%X0sKJ0k$M)#IdT-6AJ(jp$L60~@^&a+Wg?V7|4f3Mo3 z`T%Qq{%L&|X$dyBl?C@=h{uB*oUC^k;-p`Kw$H1MjEt)MRn+x%=xq*P{gCHWYY3dq zwS*QLn0Gl^+lz8wVe2FW<)o3fsktfI2T$)SjT~}rlyZjMx`-+=*`NnPn$)E_$y)~s z0YcfxrXQ0kaDX_cIbi$7vV56vfS?XH*i}Erzx{A&LbzwI$-Bje^`4>z=7Y@#c+b7w zl$kS!{GK=2de56|9RfHAi;RX|TFSMe_AJrR?<~;@f3-xL=gkdKmI?u2mR;{N+n)D% zYBg-<)Q5sX=W{rnuDadem$aeIKu(~p&u{W4o_`{L3Mv-ai-Q24ER!voE@7&}TjF>} zM|7E@N~gfM*gsB#NbRLTx`%huAhmK$OBf^XkGA`coq$|;D8eqqIr^U6UR-dwxNChQ z-x5w{U@fdj>ey8Pf`d82oP!#3Z6a^rc zKcJApF#GbHS#7=2bT9L2D;J&@;$>iz63h?ba251rTm|n3{0UCUSUT8|Nw%Z>LXO(E zdgx@nvwEBkH|pL^5beTn5}-#O0~#`ujr{uo*v8~I!N zI6Ljd?d@rRtcdgXS&<3p0vb6O*V^iW=O_*VbQ=c4LcG%l%DOlQIj*$FW@q)~y)wHW zyl7Y@+bRCXHYHe_s^~KwHoWE~p@a5`bXF3(+%flrpQ{)-%csknr8>l%lbV4!c<=o{ zpD~k2_>6=&rEeI7WJ^`RYne|# z9$VwBkX*k}R^G>Ml*ja@32Kz>7&8#^k|-;1$@k?2nZ6|g&Q5|K`7sPRNLh|!$@wUm z82vPK&3ba8wcv=94Nsy}ca9r5xOJk=P-%g08#(AFZW6avjoN+?N8^@f=vsb zZXSP|-zI%&^6WD8goW(=!1ny0_~dgRJz1YWkt+niY$h|S0IClz_Y}d7U5!B7Am|n~ zOg8jN3X4ib=GABCRyA*e$2|7|bTlXIjP6aRIs3PJ!@mfy69b0-HJ_d0R|u|cONkE_ zGLGWdfLak0Th%t(t!@d0-{a2x|4rOkcYvhWE((Ty4_(tG#zi8YVSsj3r6dUBcnw_A zNjxno^6XAC^}Psnl;;?e#ONrE|HP*KW!p;k8NP=&SE?s`O1*Y?NT83)SW8i5 zb0J7u&HK_Sr<-4#F=N^>@9)nPqh94Yqx7!{W;by*wzAIS|5E3ywa3<=v06DN-GigoOHKsOZ1Nt z1kleV2=shR`3=>O>l--}0u(hEFj-$OeocYRz`IVFZWZM@A8pu?^qRiWbYg6R>jsjy zx?Yj>)>G&t>;U{lfm4>^)iwN~U@U5_u)zl5}u_{H+3cxMy^v+q}kU*1G*dKL`B9pB+2 zRIrVZND*AR`$~MMs6w9R>1P%FzG(+J*r{u;o%`^S$k%&g2vD;wOR}NM-TVI8h)~%J z|7rrWj-u)2`#nXX_M;RDiz$=9o8fETz$p7$7LM@z*bUI^uAY8>en3>5T8hWr<0c0g zixIecYW8X|cD7c{d~hYhgsIhrS>^jhorB!r>pBOk9z94s&>zF#$pBm$4)S+{ehbj# z>4^SKfW|Y{5xG81-JMa_l4}FN&W<+54+(sfd;mK($*2?N!JiR)S4VNT4v^6lh@pcC zc2|27r-Ym^8@n2Gxlh1fte^_w4*U#&GBK2;Uo0xdB3YxhK?@_uD|?CG`sn!AY!<*! zPj_$6wLL()QUK)3UW9|fu*k}esiQLDzl1G0feVdL#*CbTwHhH;`@DS#_0rtpUPpc# zF@ZX&6SmIsow+mOn-}TA)50BVdP0oNZ5}ZAJRpB_%J_cOu_}GP_)$P#!r3WV9l+_^ zAzz(ykvRyg0=7_Amec<5S(8B7a|KibM}k#w<2lK;YE@OiU`E;KqjIlj^F$P2*jPX3qVKh`V);Ku1sfE&<{ z05@{N(HbNt0tSn+9jvnylWajlO*gkhEXucgfaWx}z2{ zH*;SUI<;*CSKUk4?oLNCU#Fu-2)c!&8OEPXNB6+hr}JQhG{jvur4zYqoA2eLp} z#Y10+48 zKaG@8MuJta*})n3cD#WLbP*|%eaQUA`xa>Rkg#UzV{>^-o29xPZ>6Nl>W3!^o|Vp^_J_u z9*zJ(Ba6!aI2-*lBk~_+BR;^0{CBev_W+5FI>_d`y*lJ?_UatQGvBze0xsXZV1YB^ z&tI?s!2Wjs0C(1i)`Z)FB^xE)MmtK|CJ%Wk9BSbHy7*v%QG_i!kZ@W&BE7 zC)C!RkUTd!w8DnqL8x5ix-3p+GbVZAW}x#9IM?jHbFL|A%i#PHB0tI9J5bgsy1wRO zXOfaay}ONTjrh@Qw7UrS4`w5_|7A8B3Z!obrL8;vHvIp-QYO%Hes8isIr1E-cjKdf zeEVtrJU)uB?d$N*-`(9hD5DVFKomc!uS%B+>k&ckV`@3cm%GiJD`o1=$lwn&ZGwYC zoO1*z>>~cRda;>Rpe6kqY2(c(q!7 ze+6nW8jSi&t}o(W6JB!#MKt8g*Q+_e8W-g?Ex_lX^T^;Qom93LHn}ENQW~*PdQ4K^pDh-d}tv9v1 zJW?M3e&f#ENwjg)r4CFD#?I3NQ}j9Nqk-SRf~O``AL@>!!!w6y)IN9aHqpScpbg^&ne^sf2)? z6>DO#e=-Hlb*`uaU1$j;ZfY#ou>C?^%?7|^l(5D=Q_tkOU-T)N1_p3;67Rc z2t{Z~38>Dz7ktNRQA^b#bU8Qfm#V+CQ@NQ@sLBawmzXR96+DwHswK||Bk}5J;9c!g zdJ?lht)e-C@Rd{*gcJ08iE9#X}G8GBnEilJ+pH-@4RVS++Ku={|xjcjJe{^i%Op z6Sefp{l*)&BJ41dpqktjOQZPGNbblJ`8`(t63+Rymx~IN70xw$iVQsm^t`A%#9mC=sNz zzVJM6KuT+uS^a|Gsb(g4stw@WVG0PCE2;^>)h1NJMxm=ym%^U_+`|ZQ0WOo*+x;1Ul~%3! z6~&<{q9daG^G`5kZfJ_0C(StV+Zyo9BblDC2Uf3xq#RE1r5Su7a&9>vxPz9=mVo;! zf$xH%U0x~6oZ1fzsXIoGmuJg)bip~Z}3XK-NDz`aKIS#VE_H4;% zCHR~)18Hg8*F7TqAMlqX+SkdHWLPF+5pVG_BRpnQ=$(KhlPInZv#r0sY9Er_Jg_3y zq#H5=+6{UpeG7W>uT>5zNojN*7^rDR7d$P01j}xC;LLl83L!zW=Bc%~`96x=>zJ&% z)dPl*YL}Par~=FNa$EY83)_DAUx+s3gu$Pk1)4H*%QHMf7I~7f3)N;8l?b-(Tt|Lw zaj25Y&m5kamcM-$iPKJ89eOykiZWK}F76_A5bF?w`sQ`lE9^8PYUfARl-|e_Epb@ba|PCG zw}Wl{h-JMS>K^w1cfDZBuZHoncwn#aeLLlcdjwgk$ z)gu%p&}*wbC0L$1)ZIbO_H@gE+kHw2lQvPOv-7Tp2+)q7h4NLt_V1PmVRAXa?Y#nX zA7V%^6$a$=2aU6A0aubM65QT+ZM9xG=JoHd!#!D2xH>e>OaRYW0PWLeJHB+o0%ByO z3&O7Y#i7_JURW_HIx61Pfyx3FArx0s0%H7Xv~fYvi-`KDfjCve2glD6Ln)%b=z^N~ zwz(bhNs%2+w;S00jzPK1-tD|&7s&?|Uo22D1vfsu`e|{i#)YBzM#!wT!oBsz-zLQ0 z*Ta9}DH>RKZ?FMU2)K4KoUC{^$kQA*9sZCYg~+7gf3r(ZJV~G@5+^sADC};w^f!g3 z!`VC~MA2#poO5u(ppFiIQPL-j1K)*OTK%y!8#O8#B`40SAo%R<;m22eE2Fhd-P<7+ z6OI?reL!nqC6iWb#$Nvmk^XDM=LG7%_+1Lyb%%gm$fC~HDZF=_Dt0BlxSGyO-^`9+ zElfB+$Wt$oeEKWt+OMcS^Jp@;X6_{wwvMu;s*T$0@&j377?nde7Ot5bQ6hq2qrA6ZHGnSAHsUl)H^0xqsXw1Kp;>j9&ZB< zJqXJachC1wOf>Y_UztQuB=-C#V-N*v{ z6o{pB%#~^27TPQ(XzGo=+GBTb*DD*un)Eho(Bg2jz^ZXU7(W-vC;07|*#nN6So-Rj zxR2O;GoHF|$a9H{k2sq#J_>p9+2PZ({g)wPL?0()UsQOHX&xm5P8Bg`);Dw8*|^1C ze}1(>Mq8eazq%%k9rCm)@iH6i)bTb>52AZ8c`78J7`FNkQog+ z+MU!t72cD$4wsH8@Uz@L+;`hLWbaXp+j~^I6CTw8=vKe^+;UR*xuCk5VvrY_MFd&| zS4?b9LnTz_519rwm*)4CZid+U9PpKGP@Ev<} z7CQxEEHBf!%5vw9#~`L(n4okDU1Gja|4uAf^<=>X_tV9#uJ-;(Xem#C9%RX)eLK5r zY^U?Jt$m|PR%a|SDOB^Mi^2rK-f;Lm74t<3v%4p&h%C)KwK*^iLmd(V+v(_L3-ul3 z&%MM*sejM$Ta4acg^>ggJ6E&N-@$>RDYTvyIIu8j;FJ2M#HG&!v zfC4~Ly;Kv94X=>rjF?6~hVx`oP9o7N7vsxFg@L8$cM97iP)7D~^TFV`coaYZwteGovP^J(AT8n+EfOP_5 z)2l02b5~v6cz81sDucUK;E+CLEgCLf{irifF^4tAfisBqK8XPh$4L@|JUjE!y#)DL zU;(g zWqnT2g8Y|XX855UIVI(D~4hKe2KFz%|b$_Lh@OQNWvEY3& zYyq)4Fq8pJj=vGBYYQoZ)9X4u$^a5!wb~?%4p{B0`|87w-B($Y|890JBfgGD0~Af8 zjc*jq;Ga`8TPKr7$zqJ9SR{;Nr>sTPHh#gkgt~@5ijoVGGv<7t8R@UUoy=YI{(Zvu zKHX=r@F%-|ZmB&#cgo-RxhIzIEbiA#v>UbKv<{Nxdh>cT$Uev+-20z diff --git a/espurna/boiler-espurna.ino b/espurna/boiler-espurna.ino index 95e073e7e..14c0bd602 100644 --- a/espurna/boiler-espurna.ino +++ b/espurna/boiler-espurna.ino @@ -166,9 +166,10 @@ void showInfo() { myDebug("\n # EMS type handlers: %d\n", ems_getEmsTypesCount()); - myDebug(" Thermostat is %s, Poll is %s, Shower timer is %s, Shower alert is %s\n", + myDebug(" Thermostat is %s, Poll is %s, Tx is %s, Shower Timer is %s, Shower Alert is %s\n", ((Boiler_Status.thermostat_enabled) ? "enabled" : "disabled"), ((EMS_Sys_Status.emsPollEnabled) ? "enabled" : "disabled"), + ((EMS_Sys_Status.emsTxEnabled) ? "enabled" : "disabled"), ((Boiler_Status.shower_timer) ? "enabled" : "disabled"), ((Boiler_Status.shower_alert) ? "enabled" : "disabled")); diff --git a/platformio.ini-example b/platformio.ini-example index f9bf4eaf3..82ee7f9bf 100644 --- a/platformio.ini-example +++ b/platformio.ini-example @@ -1,10 +1,11 @@ [platformio] +; change this for your ESP8266 device env_default = nodemcuv2 ; env_default = d1_mini [common] platform = espressif8266 -; optional flags are -DUSE_LED -DSHOWER_TEST -DDEBUG -DUSE_SERIAL -DNO_TX +; optional flags are -DUSE_LED -DSHOWER_TEST -DUSE_SERIAL build_flags = -g -w -DMQTT_MAX_PACKET_SIZE=400 build_flags_custom = '-DWIFI_SSID="my_ssid"' '-DWIFI_PASSWORD="my_password"' '-DMQTT_IP="my_broker_ip"' '-DMQTT_USER="my_broker_username"' '-DMQTT_PASS="my_broker_password"' lib_deps = @@ -12,6 +13,7 @@ lib_deps = PubSubClient ArduinoJson CRC32 + CircularBuffer [env:nodemcuv2] board = nodemcuv2 @@ -22,6 +24,10 @@ build_flags = ${common.build_flags} ${common.build_flags_custom} upload_speed = 921600 ; comment out next line if using USB and not OTA upload_port = "boiler." +; examples.... +;upload_port = "boiler" +;upload_port = "boiler.local" +;upload_port = 10.10.10.6 [env:d1_mini] board = d1_mini @@ -32,6 +38,8 @@ build_flags = ${common.build_flags} ${common.build_flags_custom} upload_speed = 921600 ; comment out next line if using USB and not OTA upload_port = "boiler." +; examples.... ;upload_port = "boiler" ;upload_port = "boiler.local" +;upload_port = 10.10.10.6 diff --git a/src/ESPHelper.cpp b/src/ESPHelper.cpp index b73ff1338..55284d2e0 100644 --- a/src/ESPHelper.cpp +++ b/src/ESPHelper.cpp @@ -70,7 +70,7 @@ ESPHelper::ESPHelper(netInfo * startingNet) { //start the wifi & mqtt systems and attempt connection (currently blocking) //true on: parameter check validated //false on: parameter check failed -bool ESPHelper::begin(const char * hostname) { +bool ESPHelper::begin(const char * hostname, const char * app_name, const char * app_version) { #ifdef USE_SERIAL1 Serial1.begin(115200); Serial1.setDebugOutput(true); @@ -85,6 +85,10 @@ bool ESPHelper::begin(const char * hostname) { strcpy(_hostname, hostname); OTA_enable(); + strcpy(_app_name, app_name); // app name + strcpy(_app_version, app_version); // app version + + setBoottime(""); if (_ssidSet) { @@ -176,7 +180,7 @@ bool ESPHelper::begin(const char * hostname) { consoleShowHelp(); // show this at bootup - //mark the system as started and return + // mark the system as started and return _hasBegun = true; return true; @@ -341,6 +345,12 @@ void ESPHelper::setWifiCallback(void (*callback)()) { _wifiCallbackSet = true; } +//sets a custom function to run when telnet is started +void ESPHelper::setInitCallback(void (*callback)()) { + _initCallback = callback; + _initCallbackSet = true; +} + //attempts to connect to wifi & mqtt server if not connected void ESPHelper::reconnect() { static uint8_t tryCount = 0; @@ -614,6 +624,8 @@ void ESPHelper::consoleHandle() { // Show the initial message consoleShowHelp(); + _initCallback(); // call callback to set any custom things + // Empty buffer while (telnetClient.available()) { telnetClient.read(); @@ -655,7 +667,7 @@ void ESPHelper::consoleHandle() { // Inactivity - close connection if not received commands from user in telnet to reduce overheads if ((millis() - _lastTimeCommand) > MAX_TIME_INACTIVE) { - telnetClient.println("* Closing session due to inactivity"); + telnetClient.println("* Closing telnet session due to inactivity"); telnetClient.flush(); telnetClient.stop(); _telnetConnected = false; @@ -749,6 +761,10 @@ void ESPHelper::consoleShowHelp() { help += "* Connected to WiFi AP: " + WiFi.SSID() + "\n\r"; help += "* Boot time: "; help.concat(_boottime); + help += "\n\r* "; + help.concat(_app_name); + help += " Version "; + help.concat(_app_version); help += "\n\r* Free RAM: "; help.concat(ESP.getFreeHeap()); help += " bytes\n\r"; diff --git a/src/ESPHelper.h b/src/ESPHelper.h index cf079779c..b50e6653e 100644 --- a/src/ESPHelper.h +++ b/src/ESPHelper.h @@ -90,7 +90,8 @@ class ESPHelper : public Print { ESPHelper(netInfo * startingNet); - bool begin(const char * hostname); + bool begin(const char * hostname, const char * app_name, const char * app_version); + void end(); void useSecureClient(const char * fingerprint); @@ -110,6 +111,7 @@ class ESPHelper : public Print { void setMQTTCallback(MQTT_CALLBACK_SIGNATURE); void setWifiCallback(void (*callback)()); + void setInitCallback(void (*callback)()); void sendHACommand(const char * s); void sendStart(); @@ -162,6 +164,8 @@ class ESPHelper : public Print { char _clientName[40]; void (*_wifiCallback)(); bool _wifiCallbackSet = false; + void (*_initCallback)(); + bool _initCallbackSet = false; std::function _mqttCallback; @@ -181,7 +185,7 @@ class ESPHelper : public Print { netInfo ** _netList; bool _verboseMessages = true; subscription _subscriptions[MAX_SUBSCRIPTIONS]; - char _hostname[64]; + char _hostname[24]; uint8_t _qos = DEFAULT_QOS; IPAddress _apIP = IPAddress(192, 168, 1, 254); void changeNetwork(); @@ -190,7 +194,9 @@ class ESPHelper : public Print { void resubscribe(); uint8_t setConnectionStatus(); - char _boottime[50]; + char _boottime[24]; + char _app_name[24]; + char _app_version[10]; // console/telnet specific WiFiClient telnetClient; diff --git a/src/boiler.ino b/src/boiler.ino index 0ef7fef55..e236ad58e 100644 --- a/src/boiler.ino +++ b/src/boiler.ino @@ -11,6 +11,7 @@ #include "ems.h" #include "emsuart.h" #include "my_config.h" +#include "version.h" // public libraries #include // https://github.com/bblanchon/ArduinoJson @@ -20,13 +21,13 @@ #include // https://github.com/esp8266/Arduino/tree/master/libraries/Ticker // timers, all values are in seconds -#define PUBLISHVALUES_TIME 300 // every 5 mins post HA values +#define PUBLISHVALUES_TIME 120 // every 2 minutes post HA values Ticker publishValuesTimer; #define SYSTEMCHECK_TIME 10 // every 10 seconds check if Boiler is online and execute other requests Ticker systemCheckTimer; -#define REGULARUPDATES_TIME 60 // every minute a call is made, so for our 2 calls theres a write cmd every 30seconds +#define REGULARUPDATES_TIME 60 // every minute a call is made Ticker regularUpdatesTimer; #define HEARTBEAT_TIME 1 // every second blink heartbeat LED @@ -37,11 +38,7 @@ Ticker scanThermostat; #define SCANTHERMOSTAT_TIME 4 uint8_t scanThermostat_count; -Ticker showerColdShotStopTimer; -uint8_t regularUpdatesCount = 0; - -#define MAX_MANUAL_CALLS 2 // number of ems reads we do during the fetch cycle (in regularUpdates) - +Ticker showerColdShotStopTimer; // GPIOs #define LED_HEARTBEAT LED_BUILTIN // onboard LED @@ -62,11 +59,9 @@ uint8_t regularUpdatesCount = 0; #define TOPIC_THERMOSTAT_MODE "thermostat_mode" // mode // boiler -#define TOPIC_BOILER_DATA MQTT_BOILER "boiler_data" // for sending boiler values -#define TOPIC_BOILER_ MQTT_BOILER "boiler_wwtemp" // warm water selected temp -#define TOPIC_BOILER_WARM_WATER_SELECTED_TEMPERATURE MQTT_BOILER "boiler_wwtemp" // warm water selected temp -#define TOPIC_BOILER_TAPWATER_ACTIVE MQTT_BOILER "tapwater_active" // if hot tap water is running -#define TOPIC_BOILER_HEATING_ACTIVE MQTT_BOILER "heating_active" // if heating is on +#define TOPIC_BOILER_DATA MQTT_BOILER "boiler_data" // for sending boiler values +#define TOPIC_BOILER_TAPWATER_ACTIVE MQTT_BOILER "tapwater_active" // if hot tap water is running +#define TOPIC_BOILER_HEATING_ACTIVE MQTT_BOILER "heating_active" // if heating is on // shower time #define TOPIC_SHOWERTIME MQTT_BOILER "showertime" // for sending shower time results @@ -94,8 +89,6 @@ const unsigned long SHOWER_OFFSET_TIME = 0; // 0 seconds grace time, to ca typedef struct { bool wifi_connected; - bool boiler_online; - bool thermostat_enabled; bool shower_timer; // true if we want to report back on shower times bool shower_alert; // true if we want the cold water reminder } _Boiler_Status; @@ -109,31 +102,33 @@ typedef struct { } _Boiler_Shower; // ESPHelper -netInfo homeNet = {.mqttHost = MQTT_IP, +netInfo homeNet = {.mqttHost = MQTT_IP, .mqttUser = MQTT_USER, .mqttPass = MQTT_PASS, .mqttPort = 1883, // this is the default, change if using another port .ssid = WIFI_SSID, .pass = WIFI_PASSWORD}; - ESPHelper myESP(&homeNet); command_t PROGMEM project_cmds[] = { - {"v [n]", "set logging (0=none, 1=basic, 2=thermostat only, 3=verbose)"}, + {"l [n]", "set logging (0=none, 1=raw, 2=basic, 3=thermostat only, 4=verbose)"}, {"s", "show statistics"}, {"h", "list supported EMS telegram type IDs"}, - {"P", "publish all stat to MQTT"}, - {"p", "toggle EMS Poll response on/off"}, + {"M", "publish to MQTT"}, + {"Q", "print Tx Queue"}, + {"P", "toggle EMS Poll response on/off"}, + {"X", "toggle EMS Tx transmission on/off"}, {"S", "toggle Shower timer on/off"}, {"A", "toggle shower Alert on/off"}, - {"b [xx]", "boiler request (xx=telegram type ID)"}, + {"r [s]", "send raw telegram to EMS (s=XX XX XX...)"}, + {"b [xx]", "send boiler read request (xx=telegram type ID in hex)"}, + {"t [xx]", "send thermostat read request (xx=telegram type ID in hex)"}, {"w [nn]", "set boiler warm water temperature (min 30)"}, - {"a [n]", "boiler warm water (1=on, 2=off)"}, - {"t [xx]", "thermostat request (xx=telegram type ID)"}, + {"a [n]", "set boiler warm tap water (0=off, 1=on)"}, {"T [xx]", "set thermostat temperature"}, - {"m [n]", "set thermostat mode (1=manual, 2=auto)"}, - {"x [xx]", "experimental code for debugging."} + {"m [n]", "set thermostat mode (1=manual, 2=auto)"} + //{"U [c]", "do a thermostat scan on all ids (c=start id) for debugging only"} }; @@ -160,6 +155,7 @@ const unsigned long TX_HOLD_LED_TIME = 2000; // how long to hold the Tx LED bec unsigned long timestamp; // for internal timings, via millis() static int connectionStatus = NO_CONNECTION; +int boilerStatus = false; bool startMQTTsent = false; uint8_t last_boilerActive = 0xFF; // for remembering last setting of the tap water or heating on/off @@ -172,18 +168,14 @@ void myDebugLog(const char * s) { if (ems_getLogging() != EMS_SYS_LOGGING_NONE) { myDebug("%s\n", s); } -#ifdef DEBUG - myESP.logger(LOG_HA, s); -#endif } // convert float to char -//char * _float_to_char(char * a, float f, uint8_t precision = 1); char * _float_to_char(char * a, float f, uint8_t precision = 1) { long p[] = {0, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000}; char * ret = a; - // check for 0x8000 (sensor missing), which has a -1 value + // check for 0x8000 (sensor missing) if (f == EMS_VALUE_FLOAT_NOTSET) { strcpy(ret, "?"); } else { @@ -272,58 +264,20 @@ void showInfo() { myDebug("\n # EMS type handlers: %d\n", ems_getEmsTypesCount()); - myDebug(" Thermostat is %s, Poll is %s, Shower timer is %s, Shower alert is %s\n", - ((Boiler_Status.thermostat_enabled) ? "enabled" : "disabled"), + myDebug(" Thermostat is %s, Poll is %s, Tx is %s, Shower Timer is %s, Shower Alert is %s\n", + (ems_getThermostatEnabled() ? "enabled" : "disabled"), ((EMS_Sys_Status.emsPollEnabled) ? "enabled" : "disabled"), + ((EMS_Sys_Status.emsTxEnabled) ? "enabled" : "disabled"), ((Boiler_Status.shower_timer) ? "enabled" : "disabled"), ((Boiler_Status.shower_alert) ? "enabled" : "disabled")); - myDebug(" EMS Bus Stats: Connected=%s, # Rx telegrams=%d, # Tx telegrams=%d, # Crc Errors=%d, ", - (Boiler_Status.boiler_online ? "yes" : "no"), + myDebug(" EMS Bus Stats: Connected=%s, # Rx telegrams=%d, # Tx telegrams=%d, # Crc Errors=%d\n", + (ems_getBoilerEnabled() ? "yes" : "no"), EMS_Sys_Status.emsRxPgks, EMS_Sys_Status.emsTxPkgs, EMS_Sys_Status.emxCrcErr); - myDebug("Rx Status="); - switch (EMS_Sys_Status.emsRxStatus) { - case EMS_RX_IDLE: - myDebug("idle"); - break; - case EMS_RX_ACTIVE: - myDebug("active"); - break; - } - - myDebug(", Tx Status="); - switch (EMS_Sys_Status.emsTxStatus) { - case EMS_TX_IDLE: - myDebug("idle"); - break; - case EMS_TX_PENDING: - myDebug("pending"); - break; - case EMS_TX_ACTIVE: - myDebug("active"); - break; - } - - myDebug(", Last Tx Action="); - switch (EMS_TxTelegram.action) { - case EMS_TX_READ: - myDebug("read"); - break; - case EMS_TX_WRITE: - myDebug("write"); - break; - case EMS_TX_VALIDATE: - myDebug("validate"); - break; - case EMS_TX_NONE: - myDebug("none"); - break; - } - - myDebug("\n\n%sBoiler stats:%s\n", COLOR_BOLD_ON, COLOR_BOLD_OFF); + myDebug("\n%sBoiler stats:%s\n", COLOR_BOLD_ON, COLOR_BOLD_OFF); // active stats myDebug(" Hot tap water is %s\n", (EMS_Boiler.tapwaterActive ? "running" : "off")); @@ -373,15 +327,22 @@ void showInfo() { EMS_Boiler.heatWorkMin % 60); // Thermostat stats - if (Boiler_Status.thermostat_enabled) { + if (ems_getThermostatEnabled()) { myDebug("\n%sThermostat stats:%s\n", COLOR_BOLD_ON, COLOR_BOLD_OFF); - myDebug(" Thermostat time is %02d:%02d:%02d %d/%d/%d\n", - EMS_Thermostat.hour, - EMS_Thermostat.minute, - EMS_Thermostat.second, - EMS_Thermostat.day, - EMS_Thermostat.month, - EMS_Thermostat.year + 2000); + myDebug(" Thermostat type: "); + ems_printThermostatType(); + myDebug("\n Thermostat time is "); + if (EMS_ID_THERMOSTAT != EMS_ID_THERMOSTAT_EASY) { + myDebug("%02d:%02d:%02d %d/%d/%d\n", + EMS_Thermostat.hour, + EMS_Thermostat.minute, + EMS_Thermostat.second, + EMS_Thermostat.day, + EMS_Thermostat.month, + EMS_Thermostat.year + 2000); + } else { + myDebug("\n"); + } _renderFloatValue("Setpoint room temperature", "C", EMS_Thermostat.setpoint_roomTemp); _renderFloatValue("Current room temperature", "C", EMS_Thermostat.curr_roomTemp); @@ -444,11 +405,9 @@ void publishValues(bool force) { crc.update(data[i]); } uint32_t checksum = crc.finalize(); - //myDebug("Boiler HASH=%d %08x, len=%d, s=%s\n", checksum, checksum, len, data); - if ((previousBoilerPublishCRC != checksum) || force) { previousBoilerPublishCRC = checksum; - if (ems_getLogging() == EMS_SYS_LOGGING_VERBOSE) { + if (ems_getLogging() >= EMS_SYS_LOGGING_BASIC) { myDebug("Publishing boiler data via MQTT\n"); } @@ -458,8 +417,8 @@ void publishValues(bool force) { // see if the heating or hot tap water has changed, if so send // last_boilerActive stores heating in bit 1 and tap water in bit 2 - if (last_boilerActive != ((EMS_Boiler.tapwaterActive << 1) + EMS_Boiler.heatingActive)) { - if (ems_getLogging() == EMS_SYS_LOGGING_VERBOSE) { + if ((last_boilerActive != ((EMS_Boiler.tapwaterActive << 1) + EMS_Boiler.heatingActive)) || force) { + if (ems_getLogging() >= EMS_SYS_LOGGING_BASIC) { myDebug("Publishing hot water and heating state via MQTT\n"); } myESP.publish(TOPIC_BOILER_TAPWATER_ACTIVE, EMS_Boiler.tapwaterActive == 1 ? "1" : "0"); @@ -468,9 +427,8 @@ void publishValues(bool force) { last_boilerActive = ((EMS_Boiler.tapwaterActive << 1) + EMS_Boiler.heatingActive); // remember last state } - // handle the thermostat values separately - if (EMS_Sys_Status.emsThermostatEnabled) { + if (ems_getThermostatEnabled()) { // only send thermostat values if we actually have them if (((int)EMS_Thermostat.curr_roomTemp == (int)0) || ((int)EMS_Thermostat.setpoint_roomTemp == (int)0)) return; @@ -498,11 +456,9 @@ void publishValues(bool force) { crc.update(data[i]); } uint32_t checksum = crc.finalize(); - //myDebug("Thermostat HASH=%d %08x, len=%d, s=%s\n", checksum, checksum, len, data); - if ((previousThermostatPublishCRC != checksum) || force) { previousThermostatPublishCRC = checksum; - if (ems_getLogging() == EMS_SYS_LOGGING_VERBOSE) { + if (ems_getLogging() >= EMS_SYS_LOGGING_BASIC) { myDebug("Publishing thermostat data via MQTT\n"); } @@ -538,11 +494,15 @@ void myDebugCallback() { case 's': showInfo(); break; - case 'p': + case 'P': // toggle Poll b = !ems_getPoll(); ems_setPoll(b); break; - case 'P': + case 'X': // toggle Tx + b = !ems_getTxEnabled(); + ems_setTxEnabled(b); + break; + case 'M': //myESP.logger(LOG_HA, "Force publish values"); publishValues(true); break; @@ -557,6 +517,9 @@ void myDebugCallback() { Boiler_Status.shower_alert = !Boiler_Status.shower_alert; myESP.publish(TOPIC_SHOWER_ALERT, Boiler_Status.shower_alert ? "1" : "0"); break; + case 'Q': //print Tx Queue + ems_printTxQueue(); + break; default: myDebug("Unknown command. Use ? for help.\n"); break; @@ -564,9 +527,6 @@ void myDebugCallback() { return; } - if (len < 2) - return; - // for commands with parameters, assume command is just one letter switch (cmd[0]) { case 'T': // set thermostat temp @@ -581,15 +541,15 @@ void myDebugCallback() { case 'w': // set warm water temp ems_setWarmWaterTemp((uint8_t)strtol(&cmd[2], 0, 10)); break; - case 'v': // verbose + case 'l': // logging ems_setLogging((_EMS_SYS_LOGGING)(cmd[2] - '0')); updateHeartbeat(); break; case 'a': // set ww activate on or off if ((cmd[2] - '0') == 1) - ems_setWarmWaterActivated(true); + ems_setWarmTapWaterActivated(true); else if ((cmd[2] - '0') == 0) - ems_setWarmWaterActivated(false); + ems_setWarmTapWaterActivated(false); break; case 'b': // boiler read command ems_doReadCommand((uint8_t)strtol(&cmd[2], 0, 16), EMS_ID_BOILER); @@ -597,13 +557,16 @@ void myDebugCallback() { case 't': // thermostat command ems_doReadCommand((uint8_t)strtol(&cmd[2], 0, 16), EMS_ID_THERMOSTAT); break; + case 'r': // send raw data + ems_sendRawTelegram(&cmd[2]); + break; case 'x': // experimental, not displayed! myDebug("Calling experimental...\n"); ems_setLogging(EMS_SYS_LOGGING_VERBOSE); ems_setExperimental((uint8_t)strtol(&cmd[2], 0, 16)); // takes HEX param break; case 'U': // thermostat scan - myDebug("Doing a scan on thermostat IDs\n"); + myDebug("Doing a type ID scan on thermostat...\n"); ems_setLogging(EMS_SYS_LOGGING_THERMOSTAT); publishValuesTimer.detach(); systemCheckTimer.detach(); @@ -651,18 +614,6 @@ void MQTTcallback(char * topic, byte * payload, uint8_t length) { return; } - // boiler_warm_water_selected_temperature - if (strcmp(topic, TOPIC_BOILER_WARM_WATER_SELECTED_TEMPERATURE) == 0) { - uint8_t i = strtol((char *)payload, 0, 10); - myDebug("MQTT topic: boiler_warm_water_selected_temperature value %d\n", i); -#ifndef NO_TX - ems_setWarmWaterTemp(i); -#endif - // publish back so HA is immediately updated - publishValues(true); - return; - } - // shower timer if (strcmp(topic, TOPIC_SHOWER_TIMER) == 0) { if (payload[0] == '1') { @@ -687,9 +638,7 @@ void MQTTcallback(char * topic, byte * payload, uint8_t length) { // shower cold shot if (strcmp(topic, TOPIC_SHOWER_COLDSHOT) == 0) { - if (Boiler_Status.shower_alert) { - _showerColdShotStart(); - } + _showerColdShotStart(); return; } @@ -703,15 +652,16 @@ void MQTTcallback(char * topic, byte * payload, uint8_t length) { } } +// Init callback, which is used to set functions and call methods when telnet has started +void InitCallback() { + ems_setLogging(BOILER_DEFAULT_LOGGING); // turn off logging as default startup +} + // WifiCallback, called when a WiFi connect has successfully been established void WIFIcallback() { Boiler_Status.wifi_connected = true; #ifdef USE_LED - // turn off the LEDs since we've finished the boot loading - digitalWrite(LED_RX, LOW); - digitalWrite(LED_TX, LOW); - digitalWrite(LED_ERR, LOW); digitalWrite(LED_HEARTBEAT, HIGH); #endif @@ -727,23 +677,20 @@ void updateHeartbeat() { } else { heartbeatEnabled = false; #ifdef USE_LED - // ...and turn off LED - digitalWrite(LED_HEARTBEAT, HIGH); + digitalWrite(LED_HEARTBEAT, HIGH); // ...and turn off LED #endif } } // Initialize the boiler settings -void _initBoiler() { +void initBoiler() { // default settings - Boiler_Status.shower_timer = BOILER_SHOWER_TIMER; - Boiler_Status.shower_alert = BOILER_SHOWER_ALERT; - Boiler_Status.thermostat_enabled = BOILER_THERMOSTAT_ENABLED; - ems_setThermostatEnabled(Boiler_Status.thermostat_enabled); + Boiler_Status.shower_timer = BOILER_SHOWER_TIMER; + Boiler_Status.shower_alert = BOILER_SHOWER_ALERT; + ems_setThermostatEnabled(BOILER_THERMOSTAT_ENABLED); // init boiler Boiler_Status.wifi_connected = false; - Boiler_Status.boiler_online = false; // init shower Boiler_Shower.timerStart = 0; @@ -768,13 +715,7 @@ void do_publishValues() { void setup() { #ifdef USE_LED // set pin for LEDs - start up with all lit up while we sort stuff out - pinMode(LED_RX, OUTPUT); - pinMode(LED_TX, OUTPUT); - pinMode(LED_ERR, OUTPUT); pinMode(LED_HEARTBEAT, OUTPUT); - digitalWrite(LED_RX, HIGH); - digitalWrite(LED_TX, HIGH); - digitalWrite(LED_ERR, HIGH); digitalWrite(LED_HEARTBEAT, LOW); // onboard LED is on heartbeatTimer.attach(HEARTBEAT_TIME, heartbeat); // blink heartbeat LED #endif @@ -782,10 +723,7 @@ void setup() { // Timers using Ticker library publishValuesTimer.attach(PUBLISHVALUES_TIME, do_publishValues); // post HA values systemCheckTimer.attach(SYSTEMCHECK_TIME, do_systemCheck); // check if Boiler is online - -#ifndef NO_TX - regularUpdatesTimer.attach((REGULARUPDATES_TIME / MAX_MANUAL_CALLS), regularUpdates); // regular reads from the EMS -#endif + regularUpdatesTimer.attach(REGULARUPDATES_TIME, regularUpdates); // regular reads from the EMS // set up WiFi myESP.setWifiCallback(WIFIcallback); @@ -798,42 +736,20 @@ void setup() { myESP.addSubscription(TOPIC_THERMOSTAT_CMD_MODE); myESP.addSubscription(TOPIC_SHOWER_TIMER); myESP.addSubscription(TOPIC_SHOWER_ALERT); - myESP.addSubscription(TOPIC_BOILER_WARM_WATER_SELECTED_TEMPERATURE); myESP.addSubscription(TOPIC_BOILER_TAPWATER_ACTIVE); myESP.addSubscription(TOPIC_BOILER_HEATING_ACTIVE); myESP.addSubscription(TOPIC_SHOWER_COLDSHOT); - myESP.consoleSetCallBackProjectCmds(project_cmds, ArraySize(project_cmds), myDebugCallback); // set up Telnet commands - myESP.begin(HOSTNAME); // start wifi and mqtt services + myESP.setInitCallback(InitCallback); - // init ems stats + myESP.consoleSetCallBackProjectCmds(project_cmds, ArraySize(project_cmds), myDebugCallback); // set up Telnet commands + myESP.begin(HOSTNAME, APP_NAME, APP_VERSION); // start wifi and mqtt services + + // init ems statisitcs ems_init(); - // init Boiler specific params - _initBoiler(); -} - -// flash LEDs -// Using a faster way to write to pins as digitalWrite does a lot of overhead like pin checking & disabling interrupts -void showLEDs() { -#ifdef USE_LED - // ERR LED - if (!Boiler_Status.boiler_online) { - WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + 4, (1 << LED_ERR)); // turn on - EMS_Sys_Status.emsRxStatus = EMS_RX_IDLE; - EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; - } else { - WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + 8, (1 << LED_ERR)); // turn off - } - - // Rx LED - WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + ((EMS_Sys_Status.emsRxStatus == EMS_RX_IDLE) ? 8 : 4), (1 << LED_RX)); - - // Tx LED - // because sends are quick, if we did a recent send show the LED for a short while - uint64_t t = (timestamp - EMS_Sys_Status.emsLastTx); - WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + ((t < TX_HOLD_LED_TIME) ? 4 : 8), (1 << LED_TX)); -#endif + // init Boiler specific parameters + initBoiler(); } // heartbeat callback to light up the LED, called via Ticker @@ -856,33 +772,37 @@ void do_scanThermostat() { // do a healthcheck every now and then to see if we connections void do_systemCheck() { // first do a system check to see if there is still a connection to the EMS - Boiler_Status.boiler_online = ((timestamp - EMS_Sys_Status.emsLastPoll) < POLL_TIMEOUT_ERR); - if (!Boiler_Status.boiler_online) { - myDebug("Error! Unable to connect to EMS bus. Please check connections. Retry in 10 seconds...\n"); + if (!ems_getBoilerEnabled()) { + myDebug("Error! Unable to connect to EMS bus. Please check connections. Retry in %d seconds...\n", + SYSTEMCHECK_TIME); + } +} + +// EMS telegrams to send after startup +void firstTimeFetch() { + ems_doReadCommand(EMS_TYPE_UBAMonitorFast, EMS_ID_BOILER); // get boiler stats which usually comes every 10 sec + ems_doReadCommand(EMS_TYPE_UBAMonitorSlow, EMS_ID_BOILER); // get boiler stats which usually comes every 60 sec + ems_doReadCommand(EMS_TYPE_UBAParameterWW, EMS_ID_BOILER); // get Warm Water values + + if (ems_getThermostatEnabled()) { + ems_getThermostatValues(); // get Thermostat temps (if supported) + ems_doReadCommand(EMS_TYPE_RCTime, EMS_ID_THERMOSTAT); // get Thermostat time } } // force calls to get data from EMS for the types that aren't sent as broadcasts -// number of calls is defined in MAX_MANUAL_CALLS -// it's done as a cycle to prevent collisions, since we can only do 1 read command at a time void regularUpdates() { - uint8_t cycle = (regularUpdatesCount++ % MAX_MANUAL_CALLS); + ems_doReadCommand(EMS_TYPE_UBAParameterWW, EMS_ID_BOILER); // get Warm Water values - // only do calls if the EMS is connected and alive - if (Boiler_Status.boiler_online) { - if ((cycle == 0) && Boiler_Status.thermostat_enabled) { - // force get the thermostat data which are not usually automatically broadcasted - ems_getThermostatTemps(); - } else if (cycle == 1) { - ems_doReadCommand(EMS_TYPE_UBAParameterWW, EMS_ID_BOILER); // get Warm Water values - } + if (ems_getThermostatEnabled()) { + ems_getThermostatValues(); // get Thermostat temps (if supported) } } // turn off hot water to send a shot of cold void _showerColdShotStart() { myDebugLog("Shower: doing a shot of cold"); - ems_setWarmWaterActivated(false); + ems_setWarmTapWaterActivated(false); Boiler_Shower.doingColdShot = true; // start the timer for n seconds which will reset the water back to hot showerColdShotStopTimer.attach(SHOWER_COLDSHOT_DURATION, _showerColdShotStop); @@ -892,12 +812,101 @@ void _showerColdShotStart() { void _showerColdShotStop() { if (Boiler_Shower.doingColdShot) { myDebugLog("Shower: finished shot of cold. hot water back on"); - ems_setWarmWaterActivated(true); + ems_setWarmTapWaterActivated(true); Boiler_Shower.doingColdShot = false; showerColdShotStopTimer.detach(); } } +/* + * Shower Logic + */ +void showerCheck() { + // if already in cold mode, ignore all this logic until we're out of the cold blast + if (!Boiler_Shower.doingColdShot) { + // is the hot water running? + if (EMS_Boiler.tapwaterActive) { + // if heater was previously off, start the timer + if (Boiler_Shower.timerStart == 0) { + // hot water just started... + Boiler_Shower.timerStart = timestamp; + Boiler_Shower.timerPause = 0; // remove any last pauses + Boiler_Shower.doingColdShot = false; + Boiler_Shower.duration = 0; + Boiler_Shower.showerOn = false; +#ifdef SHOWER_TEST + myDebugLog("Shower: hot water on..."); +#endif + } else { + // hot water has been on for a while + // first check to see if hot water has been on long enough to be recognized as a Shower/Bath + if (!Boiler_Shower.showerOn && (timestamp - Boiler_Shower.timerStart) > SHOWER_MIN_DURATION) { + Boiler_Shower.showerOn = true; +#ifdef SHOWER_TEST + + myDebugLog("Shower: hot water still running, starting shower timer"); +#endif + } + // check if the shower has been on too long + else if ((((timestamp - Boiler_Shower.timerStart) > SHOWER_MAX_DURATION) && !Boiler_Shower.doingColdShot) + && Boiler_Status.shower_alert) { + myESP.sendHACommand(TOPIC_SHOWER_ALARM); +#ifdef SHOWER_TEST + myDebugLog("Shower: exceeded max shower time"); +#endif + _showerColdShotStart(); + } + } + } else { // hot water is off + // if it just turned off, record the time as it could be a short pause + if ((Boiler_Shower.timerStart != 0) && (Boiler_Shower.timerPause == 0)) { + Boiler_Shower.timerPause = timestamp; +#ifdef SHOWER_TEST + myDebugLog("Shower: hot water turned off"); +#endif + } + + // if shower has been off for longer than the wait time + if ((Boiler_Shower.timerPause != 0) && ((timestamp - Boiler_Shower.timerPause) > SHOWER_PAUSE_TIME)) { + /* + sprintf(s, + "Shower: duration %d offset %d", + (Boiler_Shower.timerPause - Boiler_Shower.timerStart), + SHOWER_OFFSET_TIME); + myDebugLog("s"); + */ + + // it is over the wait period, so assume that the shower has finished and calculate the total time and publish + // because its unsigned long, can't have negative so check if length is less than OFFSET_TIME + if ((Boiler_Shower.timerPause - Boiler_Shower.timerStart) > SHOWER_OFFSET_TIME) { + Boiler_Shower.duration = (Boiler_Shower.timerPause - Boiler_Shower.timerStart - SHOWER_OFFSET_TIME); + if (Boiler_Shower.duration > SHOWER_MIN_DURATION) { + char s[50]; + sprintf(s, + "%d minutes and %d seconds", + (uint8_t)((Boiler_Shower.duration / (1000 * 60)) % 60), + (uint8_t)((Boiler_Shower.duration / 1000) % 60)); + + if (ems_getLogging() != EMS_SYS_LOGGING_NONE) { + myDebug("Shower: finished with duration %s\n", s); + } + myESP.publish(TOPIC_SHOWERTIME, s); // publish to HA + } + } + +#ifdef SHOWER_TEST + // reset everything + myDebugLog("Shower: resetting timers"); +#endif + Boiler_Shower.timerStart = 0; + Boiler_Shower.timerPause = 0; + Boiler_Shower.showerOn = false; + _showerColdShotStop(); // turn hot water back on in case its off + } + } + } +} + // // Main loop // @@ -915,7 +924,7 @@ void loop() { return; } - // if first time connected to MQTT, send welcome start message + // if this is the first time we've connected to MQTT, send a welcome start message // which will send all the state values from HA back to the clock via MQTT and return the boottime if ((!startMQTTsent) && (connectionStatus == FULL_CONNECTION)) { myESP.sendStart(); @@ -924,116 +933,24 @@ void loop() { // publish to HA the status of the Shower parameters myESP.publish(TOPIC_SHOWER_TIMER, Boiler_Status.shower_timer ? "1" : "0"); myESP.publish(TOPIC_SHOWER_ALERT, Boiler_Status.shower_alert ? "1" : "0"); - -#ifndef NO_TX - if (Boiler_Status.boiler_online) { - // now that we're connected lets get some data from the EMS - ems_doReadCommand(EMS_TYPE_UBAParameterWW, EMS_ID_BOILER); - ems_setWarmWaterActivated(true); // make sure warm water if activated, in case it got stuck with the shower alert - } else { - myDebugLog("Boot: can't connect to EMS."); - } -#endif } - // publish the values to MQTT (only if there are changes) - // if we received new data and flagged for pushing, do it - if (EMS_Sys_Status.emsRefreshed) { - EMS_Sys_Status.emsRefreshed = false; - publishValues(false); + // if the EMS bus has just connected, send a request to fetch some initial values + if (ems_getBoilerEnabled() && boilerStatus == false) { + boilerStatus = true; + firstTimeFetch(); } - /* - * Shower Logic - */ + // publish the values to MQTT, regardless if the values haven't changed + if (ems_getEmsRefreshed()) { + publishValues(true); + ems_setEmsRefreshed(false); + } + + // do shower logic if its enabled if (Boiler_Status.shower_timer) { - // if already in cold mode, ignore all this logic until we're out of the cold blast - if (!Boiler_Shower.doingColdShot) { - // is the hot water running? - if (EMS_Boiler.tapwaterActive) { - // if heater was previously off, start the timer - if (Boiler_Shower.timerStart == 0) { - // hot water just started... - Boiler_Shower.timerStart = timestamp; - Boiler_Shower.timerPause = 0; // remove any last pauses - Boiler_Shower.doingColdShot = false; - Boiler_Shower.duration = 0; - Boiler_Shower.showerOn = false; -#ifdef SHOWER_TEST - myDebugLog("Shower: hot water on..."); -#endif - } else { - // hot water has been on for a while - // first check to see if hot water has been on long enough to be recognized as a Shower/Bath - if (!Boiler_Shower.showerOn && (timestamp - Boiler_Shower.timerStart) > SHOWER_MIN_DURATION) { - Boiler_Shower.showerOn = true; -#ifdef SHOWER_TEST - - myDebugLog("Shower: hot water still running, starting shower timer"); -#endif - } - // check if the shower has been on too long - else if ((((timestamp - Boiler_Shower.timerStart) > SHOWER_MAX_DURATION) - && !Boiler_Shower.doingColdShot) - && Boiler_Status.shower_alert) { - myESP.sendHACommand(TOPIC_SHOWER_ALARM); -#ifdef SHOWER_TEST - myDebugLog("Shower: exceeded max shower time"); -#endif - _showerColdShotStart(); - } - } - } else { // hot water is off - // if it just turned off, record the time as it could be a short pause - if ((Boiler_Shower.timerStart != 0) && (Boiler_Shower.timerPause == 0)) { - Boiler_Shower.timerPause = timestamp; -#ifdef SHOWER_TEST - myDebugLog("Shower: hot water turned off"); -#endif - } - - // if shower has been off for longer than the wait time - if ((Boiler_Shower.timerPause != 0) && ((timestamp - Boiler_Shower.timerPause) > SHOWER_PAUSE_TIME)) { - /* - sprintf(s, - "Shower: duration %d offset %d", - (Boiler_Shower.timerPause - Boiler_Shower.timerStart), - SHOWER_OFFSET_TIME); - myDebugLog("s"); - */ - - // it is over the wait period, so assume that the shower has finished and calculate the total time and publish - // because its unsigned long, can't have negative so check if length is less than OFFSET_TIME - if ((Boiler_Shower.timerPause - Boiler_Shower.timerStart) > SHOWER_OFFSET_TIME) { - Boiler_Shower.duration = - (Boiler_Shower.timerPause - Boiler_Shower.timerStart - SHOWER_OFFSET_TIME); - if (Boiler_Shower.duration > SHOWER_MIN_DURATION) { - char s[50]; - sprintf(s, - "%d minutes and %d seconds", - (uint8_t)((Boiler_Shower.duration / (1000 * 60)) % 60), - (uint8_t)((Boiler_Shower.duration / 1000) % 60)); - - if (ems_getLogging() != EMS_SYS_LOGGING_NONE) { - myDebug("Shower: finished with duration %s\n", s); - } - myESP.publish(TOPIC_SHOWERTIME, s); // publish to HA - } - } - -#ifdef SHOWER_TEST - // reset everything - myDebugLog("Shower: resetting timers"); -#endif - Boiler_Shower.timerStart = 0; - Boiler_Shower.timerPause = 0; - Boiler_Shower.showerOn = false; - _showerColdShotStop(); // turn hot water back on in case its off - } - } - } + showerCheck(); } - // yield to prevent watchdog from timing out - yield(); + yield(); // yield to prevent watchdog from timing out } diff --git a/src/ems.cpp b/src/ems.cpp index 07182a2ac..05cb30c2e 100644 --- a/src/ems.cpp +++ b/src/ems.cpp @@ -1,4 +1,4 @@ -/* +/** * ems.cpp * handles all the processing of the EMS messages * Paul Derbyshire - https://github.com/proddy/EMS-ESP-Boiler @@ -8,6 +8,7 @@ #include "emsuart.h" #include +#include // https://github.com/rlogiacco/CircularBuffer #include // Check for ESPurna vs ESPHelper (standalone) @@ -31,23 +32,49 @@ constexpr size_t ArraySize(T (&)[N]) { } _EMS_Sys_Status EMS_Sys_Status; // EMS Status -_EMS_TxTelegram EMS_TxTelegram; // Empty buffer for sending telegrams + +CircularBuffer<_EMS_TxTelegram, 20> EMS_TxQueue; // FIFO queue for Tx send buffer // callbacks per type -bool _process_UBAMonitorFast(uint8_t * data, uint8_t length); -bool _process_UBAMonitorSlow(uint8_t * data, uint8_t length); -bool _process_UBAMonitorWWMessage(uint8_t * data, uint8_t length); -bool _process_UBAParameterWW(uint8_t * data, uint8_t length); -bool _process_RC20StatusMessage(uint8_t * data, uint8_t length); -bool _process_RCTime(uint8_t * data, uint8_t length); -bool _process_RC20Temperature(uint8_t * data, uint8_t length); -bool _process_RCTempMessage(uint8_t * data, uint8_t length); -bool _process_Version(uint8_t * data, uint8_t length); -bool _process_SetPoints(uint8_t * data, uint8_t length); -bool _process_EasyTemperature(uint8_t * data, uint8_t length); +void _process_UBAMonitorFast(uint8_t * data, uint8_t length); +void _process_UBAMonitorSlow(uint8_t * data, uint8_t length); +void _process_UBAMonitorWWMessage(uint8_t * data, uint8_t length); +void _process_UBAParameterWW(uint8_t * data, uint8_t length); + +// Thermostat + +// Common +void _process_Version(uint8_t * data, uint8_t length); +void _process_SetPoints(uint8_t * data, uint8_t length); +void _process_RCTime(uint8_t * data, uint8_t length); +void _process_RCOutdoorTempMessage(uint8_t * data, uint8_t length); + +// RC20 +void _process_RC20Set(uint8_t * data, uint8_t length); +void _process_RC20StatusMessage(uint8_t * data, uint8_t length); + +// RC30 +void _process_RC30Set(uint8_t * data, uint8_t length); +void _process_RC30StatusMessage(uint8_t * data, uint8_t length); + +// Easy +void _process_EasyStatusMessage(uint8_t * data, uint8_t length); + +const _Thermostat_Types Thermostat_Types[] = { + + {EMS_ID_THERMOSTAT_RC20, "RC20 (Nefit Moduline 300)"}, + {EMS_ID_THERMOSTAT_RC30, "RC30 (Nefit Moduline 400)"}, + {EMS_ID_THERMOSTAT_EASY, "TC100 (Nefit Easy/CT100)"} + +}; +uint8_t _Thermostat_Types_max = ArraySize(Thermostat_Types); // number of defined thermostat types const _EMS_Types EMS_Types[] = { + // Command commands + {EMS_ID_NONE, EMS_TYPE_Version, "Version", _process_Version}, + + // Boiler commands {EMS_ID_BOILER, EMS_TYPE_UBAMonitorFast, "UBAMonitorFast", _process_UBAMonitorFast}, {EMS_ID_BOILER, EMS_TYPE_UBAMonitorSlow, "UBAMonitorSlow", _process_UBAMonitorSlow}, {EMS_ID_BOILER, EMS_TYPE_UBAMonitorWWMessage, "UBAMonitorWWMessage", _process_UBAMonitorWWMessage}, @@ -57,13 +84,22 @@ const _EMS_Types EMS_Types[] = { {EMS_ID_BOILER, EMS_TYPE_UBAParametersMessage, "UBAParametersMessage", NULL}, {EMS_ID_BOILER, EMS_TYPE_UBAMaintenanceStatusMessage, "UBAMaintenanceStatusMessage", NULL}, - {EMS_ID_THERMOSTAT, EMS_TYPE_RC20StatusMessage, "RC20StatusMessage", _process_RC20StatusMessage}, + // Thermostat commands + // common {EMS_ID_THERMOSTAT, EMS_TYPE_RCTime, "RCTime", _process_RCTime}, - {EMS_ID_THERMOSTAT, EMS_TYPE_RC20Temperature, "RC20Temperature", _process_RC20Temperature}, - {EMS_ID_THERMOSTAT, EMS_TYPE_EasyTemperature, "EasyTemperature", _process_EasyTemperature}, - {EMS_ID_THERMOSTAT, EMS_TYPE_RCTempMessage, "RCTempMessage", _process_RCTempMessage}, - {EMS_ID_THERMOSTAT, EMS_TYPE_Version, "Version", _process_Version}, - {EMS_ID_THERMOSTAT, EMS_TYPE_UBASetPoints, "UBASetPoints", _process_SetPoints} + {EMS_ID_THERMOSTAT, EMS_TYPE_RCOutdoorTempMessage, "RCOutdoorTempMessage", _process_RCOutdoorTempMessage}, + {EMS_ID_THERMOSTAT, EMS_TYPE_UBASetPoints, "UBASetPoints", _process_SetPoints}, + + // RC20 + {EMS_ID_THERMOSTAT, EMS_TYPE_RC20Set, "RC20Set", _process_RC20Set}, + {EMS_ID_THERMOSTAT, EMS_TYPE_RC20StatusMessage, "RC20StatusMessage", _process_RC20StatusMessage}, + + // RC30 + {EMS_ID_THERMOSTAT, EMS_TYPE_RC30Set, "RC30Set", _process_RC30Set}, + {EMS_ID_THERMOSTAT, EMS_TYPE_RC30StatusMessage, "RC30StatusMessage", _process_RC30StatusMessage}, + + // Easy + {EMS_ID_THERMOSTAT, EMS_TYPE_EasyStatusMessage, "EasyStatusMessage", _process_EasyStatusMessage} }; uint8_t _EMS_Types_max = ArraySize(EMS_Types); // number of defined types @@ -89,14 +125,12 @@ const uint8_t ems_crc_table[] = 0xD1, 0xD3, 0xD5, 0xD7, 0xC9, 0xCB, 0xCD, 0xCF, 0xC1, 0xC3, 0xC5, 0xC7, 0xF9, 0xFB, 0xFD, 0xFF, 0xF1, 0xF3, 0xF5, 0xF7, 0xE9, 0xEB, 0xED, 0xEF, 0xE1, 0xE3, 0xE5, 0xE7}; -// constants timers -const uint64_t RX_READ_TIMEOUT = 5000; // in ms. 5 seconds timeout for read replies -const uint8_t RX_READ_TIMEOUT_COUNT = 3; // 3 retries before timeout +const uint8_t RX_READ_TIMEOUT_COUNT = 3; // 3 retries before timeout -uint8_t emsLastRxCount; // used for retries when sending failed +uint8_t emsRxRetryCount; // used for retries when sending failed // init stats and counters and buffers -// uses -1 or 255 for values that haven't been set yet (EMS_VALUE_INT_NOTSET and EMS_VALUE_FLOAT_NOTSET) +// uses -255 or 255 for values that haven't been set yet (EMS_VALUE_INT_NOTSET and EMS_VALUE_FLOAT_NOTSET) void ems_init() { // overall status EMS_Sys_Status.emsRxPgks = 0; @@ -104,13 +138,13 @@ void ems_init() { EMS_Sys_Status.emxCrcErr = 0; EMS_Sys_Status.emsRxStatus = EMS_RX_IDLE; EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; - EMS_Sys_Status.emsLastPoll = 0; - EMS_Sys_Status.emsLastRx = 0; - EMS_Sys_Status.emsLastTx = 0; EMS_Sys_Status.emsRefreshed = false; - EMS_Sys_Status.emsPollEnabled = false; // start up with Poll disabled - EMS_Sys_Status.emsThermostatEnabled = true; // there is a RCxx thermostat active as default - EMS_Sys_Status.emsLogging = EMS_SYS_LOGGING_NONE; // Verbose logging is off + EMS_Sys_Status.emsPollEnabled = false; // start up with Poll disabled + EMS_Sys_Status.emsTxEnabled = true; // start up with Tx enabled + EMS_Sys_Status.emsThermostatEnabled = true; // there is a RCxx thermostat active as default + EMS_Sys_Status.emsBoilerEnabled = false; // boiler is not connected yet + + EMS_Sys_Status.emsLogging = EMS_SYS_LOGGING_NONE; // Verbose logging is off // thermostat EMS_Thermostat.type = EMS_ID_THERMOSTAT; // type, see my_config.h @@ -159,22 +193,6 @@ void ems_init() { EMS_Boiler.tapwaterActive = EMS_VALUE_INT_NOTSET; // Hot tap water is on/off EMS_Boiler.heatingActive = EMS_VALUE_INT_NOTSET; // Central heating is on/off - - // init the Tx package - _initTxBuffer(); -} - -// init Tx Buffer -void _initTxBuffer() { - EMS_TxTelegram.length = 0; - EMS_TxTelegram.type = 0; - EMS_TxTelegram.dest = 0; - EMS_TxTelegram.offset = 0; - EMS_TxTelegram.length = 0; - EMS_TxTelegram.type_validate = 0; - EMS_TxTelegram.action = EMS_TX_NONE; - EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; - emsLastRxCount = 0; } // Getters and Setters for parameters @@ -187,6 +205,30 @@ bool ems_getPoll() { return EMS_Sys_Status.emsPollEnabled; } +/** + * ! Getters and Setters for parameters + */ +void ems_setTxEnabled(bool b) { + EMS_Sys_Status.emsTxEnabled = b; + myDebug("EMS Bus Tx is set to %s\n", EMS_Sys_Status.emsTxEnabled ? "enabled" : "disabled"); +} + +bool ems_getTxEnabled() { + return EMS_Sys_Status.emsTxEnabled; +} + +bool ems_getBoilerEnabled() { + return EMS_Sys_Status.emsBoilerEnabled; +} + +bool ems_getEmsRefreshed() { + return EMS_Sys_Status.emsRefreshed; +} + +void ems_setEmsRefreshed(bool b) { + EMS_Sys_Status.emsRefreshed = b; +} + bool ems_getThermostatEnabled() { return EMS_Sys_Status.emsThermostatEnabled; } @@ -204,6 +246,10 @@ uint8_t ems_getEmsTypesCount() { return _EMS_Types_max; } +uint8_t ems_getThermostatTypesCount() { + return _Thermostat_Types_max; +} + void ems_setLogging(_EMS_SYS_LOGGING loglevel) { if (loglevel <= EMS_SYS_LOGGING_VERBOSE) { EMS_Sys_Status.emsLogging = loglevel; @@ -216,11 +262,13 @@ void ems_setLogging(_EMS_SYS_LOGGING loglevel) { myDebug("Verbose\n"); } else if (loglevel == EMS_SYS_LOGGING_THERMOSTAT) { myDebug("Thermostat only\n"); + } else if (loglevel == EMS_SYS_LOGGING_RAW) { + myDebug("Raw mode\n"); } } } -/* +/** * Calculate CRC checksum using lookup table for speed * len is length of data in bytes (including the CRC byte at end) */ @@ -236,12 +284,26 @@ uint8_t _crcCalculator(uint8_t * data, uint8_t len) { return crc; } -// function to turn a telegram int (2 bytes) to a float. The source is *10 +/** + * function to turn a telegram int (2 bytes) to a float. The source is *10 + * negative values are stored as 1-compliment (https://medium.com/@LeeJulija/how-integers-are-stored-in-memory-using-twos-complement-5ba04d61a56c) + */ float _toFloat(uint8_t i, uint8_t * data) { - if ((data[i] == 0x80) && (data[i + 1] == 0)) // 0x8000 is used when sensor is missing - return (float)-1; // return -1 to indicate that is unknown - - return ((float)(((data[i] << 8) + data[i + 1]))) / 10; + // if the MSB is set, it's a negative number or an error + if ((data[i] & 0x80) == 0x80) { + // check if its an invalid number + // 0x8000 is used when sensor is missing + if ((data[i] == 0x80) && (data[i + 1] == 0)) { + return (float)EMS_VALUE_FLOAT_NOTSET; // return -1 to indicate that is unknown + } + // its definitely a negative number + // assume its 1-compliment, otherwise we need add 1 to the total for 2-compliment + int16_t x = (data[i] << 8) + data[i + 1]; + return ((float)(x)) / 10; + } else { + // positive number + return ((float)(((data[i] << 8) + data[i + 1]))) / 10; + } } // function to turn a telegram long (3 bytes) to a long int @@ -249,22 +311,10 @@ uint16_t _toLong(uint8_t i, uint8_t * data) { return (((data[i]) << 16) + ((data[i + 1]) << 8) + (data[i + 2])); } -// debugging only - print out all handled types -void ems_printAllTypes() { - myDebug("These %d telegram type IDs are recognized:\n", _EMS_Types_max); - - for (uint8_t i = 0; i < _EMS_Types_max; i++) { - myDebug(" %s:\ttype ID %02X (%s)\n", - EMS_Types[i].src == EMS_ID_THERMOSTAT ? "Thermostat" : "Boiler", - EMS_Types[i].type, - EMS_Types[i].typeString); - } -} - -/* +/** * Find the pointer to the EMS_Types array for a given type ID */ -int ems_findType(uint8_t type) { +int _ems_findType(uint8_t type) { uint8_t i = 0; bool typeFound = false; // scan through known ID types @@ -279,127 +329,195 @@ int ems_findType(uint8_t type) { return (typeFound ? i : -1); } -// debug print a telegram to telnet console -// len is length in bytes including the CRC +/** + * debug print a telegram to telnet console + * len is length in bytes including the CRC + */ void _debugPrintTelegram(const char * prefix, uint8_t * data, uint8_t len, const char * color) { - if (EMS_Sys_Status.emsLogging != EMS_SYS_LOGGING_VERBOSE) + if (EMS_Sys_Status.emsLogging <= EMS_SYS_LOGGING_BASIC) return; myDebug("%s%s telegram: ", color, prefix); - for (int i = 0; i < len; i++) { + for (int i = 0; i < len - 1; i++) { myDebug("%02X ", data[i]); } + myDebug("(CRC=%02X", data[len - 1]); - myDebug("(len %d)%s\n", len, COLOR_RESET); + // print number of data bytes only if its a valid telegram + if (len > 5) { + myDebug(", #data=%d", (len - 5)); + } + myDebug(")%s\n", COLOR_RESET); } -// send the contents of the Tx buffer +/** + * send the contents of the Tx buffer to the UART + * we take telegram from the queue and send it, but don't remove it until later when its confirmed successful + */ void _ems_sendTelegram() { - // only send when Tx is not busy - char s[50]; + // check if we have something in the queue to send + if (EMS_TxQueue.isEmpty()) { + return; + } - if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE) { - sprintf(s, - "Sending %s to 0x%02X:", - ((EMS_TxTelegram.action == EMS_TX_WRITE) ? "write" : "read"), + // get the first in the queue, which is at the head + // we don't remove from the queue yet + _EMS_TxTelegram EMS_TxTelegram = EMS_TxQueue.first(); + + // if we're in raw mode just fire and forget + if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_RAW) { + EMS_TxTelegram.data[EMS_TxTelegram.length - 1] = + _crcCalculator(EMS_TxTelegram.data, EMS_TxTelegram.length); // add the CRC + _debugPrintTelegram("Sending raw", EMS_TxTelegram.data, EMS_TxTelegram.length, COLOR_CYAN); // always show + emsuart_tx_buffer(EMS_TxTelegram.data, EMS_TxTelegram.length); // send the telegram to the UART Tx + EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; // finished sending + EMS_TxQueue.shift(); // remove from queue + return; + } + + // if Tx is disabled, don't do anything and ignore the request + if (!EMS_Sys_Status.emsTxEnabled) { + myDebug("Tx is disabled. Ignoring %s request to 0x%02X.\n", + ((EMS_TxTelegram.action == EMS_TX_TELEGRAM_WRITE) ? "write" : "read"), EMS_TxTelegram.dest & 0x7F); - _debugPrintTelegram(s, EMS_TxTelegram.data, EMS_TxTelegram.length, COLOR_CYAN); + EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; // finished sending + EMS_TxQueue.shift(); // remove from queue + return; + } + + // if this telegram has already been processed then skip it + // leave on queue until its processed later on + if (EMS_TxTelegram.hasSent) { + // myDebug("Already sent!"); + return; } EMS_Sys_Status.emsTxStatus = EMS_TX_ACTIVE; + + // create header + EMS_TxTelegram.data[0] = EMS_ID_ME; // src + // dest + if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_WRITE) { + EMS_TxTelegram.data[1] = EMS_TxTelegram.dest; + } else { + // for a READ or VALIDATE + EMS_TxTelegram.data[1] = EMS_TxTelegram.dest | 0x80; // read has 8th bit set + } + EMS_TxTelegram.data[2] = EMS_TxTelegram.type; // type + EMS_TxTelegram.data[3] = EMS_TxTelegram.offset; // offset + + // see if it has data, add the single data value byte + // otherwise leave it alone and assume the data has been pre-populated + if (EMS_TxTelegram.length == EMS_MIN_TELEGRAM_LENGTH) { + // for reading this is #bytes we want to read (the size) + // for writing its the value we want to write + EMS_TxTelegram.data[4] = EMS_TxTelegram.dataValue; + } + // finally calculate CRC and add it + EMS_TxTelegram.data[EMS_TxTelegram.length - 1] = _crcCalculator(EMS_TxTelegram.data, EMS_TxTelegram.length); + + // print debug info + if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE) { + char s[64]; + if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_WRITE) { + sprintf(s, "Sending write of type 0x%02X to 0x%02X:", EMS_TxTelegram.type, EMS_TxTelegram.dest & 0x7F); + } else if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_READ) { + sprintf(s, "Sending read of type 0x%02X to 0x%02X:", EMS_TxTelegram.type, EMS_TxTelegram.dest & 0x7F); + } else if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_VALIDATE) { + sprintf(s, "Sending validate of type 0x%02X to 0x%02X:", EMS_TxTelegram.type, EMS_TxTelegram.dest & 0x7F); + } + + _debugPrintTelegram(s, EMS_TxTelegram.data, EMS_TxTelegram.length, COLOR_CYAN); + } + + // send the telegram to the UART Tx emsuart_tx_buffer(EMS_TxTelegram.data, EMS_TxTelegram.length); + EMS_Sys_Status.emsTxPkgs++; - EMS_Sys_Status.emsLastTx = millis(); + + // dirty hack. we really shouldn't be changing values in the buffer directly. + EMS_TxTelegram.hasSent = true; // if it was a write command, check if we need to do a new read to validate the results - if (EMS_TxTelegram.action == EMS_TX_WRITE) { - // straight after the write check to see if we have to followup with a read to verify the write worked - // we do this by re-submitting the same telegram but this time as a write command (type,offset,length are still valid) - if (EMS_TxTelegram.type_validate != 0) { - EMS_TxTelegram.dest = EMS_TxTelegram.dest | 0x80; // add the 7th bit make sure its a read - _buildTxTelegram(1); // get a single value back, which is one byte - } - EMS_TxTelegram.action = EMS_TX_VALIDATE; // will start the validate next time a telegram is received - } else { - EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; // nothing to send + // we do this by turning the last write into a read + if ((EMS_TxTelegram.action == EMS_TX_TELEGRAM_WRITE) && (EMS_TxTelegram.type_validate != EMS_ID_NONE)) { + // create a new Telegram copying from the last write + _EMS_TxTelegram new_EMS_TxTelegram; + + // copy details + new_EMS_TxTelegram.type_validate = EMS_TxTelegram.type_validate; + new_EMS_TxTelegram.dest = EMS_TxTelegram.dest; + new_EMS_TxTelegram.type = EMS_TxTelegram.type; + new_EMS_TxTelegram.action = EMS_TX_TELEGRAM_VALIDATE; + new_EMS_TxTelegram.offset = EMS_TxTelegram.comparisonOffset; // location of byte to fetch + new_EMS_TxTelegram.dataValue = 1; // fetch single byte + new_EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; // is always 6 bytes long (including CRC at end) + new_EMS_TxTelegram.comparisonValue = EMS_TxTelegram.comparisonValue; + new_EMS_TxTelegram.comparisonPostRead = EMS_TxTelegram.comparisonPostRead; + new_EMS_TxTelegram.comparisonOffset = EMS_TxTelegram.comparisonOffset; + new_EMS_TxTelegram.hasSent = false; + + // remove old telegram from queue and add this new read one + EMS_TxQueue.shift(); // remove from queue + EMS_TxQueue.unshift(new_EMS_TxTelegram); // add back to queue making it next in line } + + EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; } -/* - * parse the telegram message +/** + * the main logic that parses the telegram message * length is only data bytes, excluding the BRK * Read commands are asynchronous as they're handled by the interrupt - * When we receive a Poll Request we need to send quickly + * When we receive a Poll Request we need to send any Tx packages quickly */ void ems_parseTelegram(uint8_t * telegram, uint8_t length) { - // if we're waiting on a response from a previous read and it hasn't come, try again - if ((EMS_Sys_Status.emsTxStatus != EMS_TX_PENDING) - && ((EMS_TxTelegram.action == EMS_TX_READ) || (EMS_TxTelegram.action == EMS_TX_VALIDATE)) - && ((millis() - EMS_Sys_Status.emsLastTx) > RX_READ_TIMEOUT)) { - if (emsLastRxCount++ >= RX_READ_TIMEOUT_COUNT) { - // give up and reset tx - if (EMS_Sys_Status.emsLogging != EMS_SYS_LOGGING_NONE) { - myDebug("Error! Failed to send telegram, cancelling last write command.\n"); - } - // re-initialise - _initTxBuffer(); - } else { - if (EMS_Sys_Status.emsLogging != EMS_SYS_LOGGING_NONE) { - myDebug("Didn't receive acknowledgement from the 0x%X, so resending (attempt #%d/%d)...\n", - EMS_TxTelegram.type, - emsLastRxCount, - RX_READ_TIMEOUT_COUNT); - } - EMS_Sys_Status.emsTxStatus = EMS_TX_PENDING; // set to pending will trigger sending the same package again - } - } - - // check if we just received one byte - // it could be a Poll request from the boiler which is 0x8B (0x0B | 0x80 to set 7th bit) - // or a return code like 0x01 or 0x04 from the last Write command + // check if we just received a single byte + // it could well be a Poll request from the boiler which has an ID 0x8B (0x0B | 0x80 to set 8th bit) + // or either a return code like 0x01 or 0x04 from the last Write command issued if (length == 1) { - uint8_t value = telegram[0]; // 1st byte + uint8_t value = telegram[0]; // 1st byte of data package - // check first for Poll + // check first for a Poll for us if (value == (EMS_ID_ME | 0x80)) { // set the timestamp of the last poll, we use this to see if we have a connection to the boiler - EMS_Sys_Status.emsLastPoll = millis(); + EMS_Sys_Status.emsBoilerEnabled = true; - // do we have something to send? if so send it - if (EMS_Sys_Status.emsTxStatus == EMS_TX_PENDING) { - _ems_sendTelegram(); + // do we have something to send thats waiting in the Tx queue? if so send it + if (!EMS_TxQueue.isEmpty()) { + _ems_sendTelegram(); // perform the read/write command } else { // nothing to send so just send a poll acknowledgement back if (EMS_Sys_Status.emsPollEnabled) { emsaurt_tx_poll(); } } - // check if we're waiting on a response after a recent Write, which is a return code (0x01 for success or 0x04 for error) - } else if (EMS_TxTelegram.action == EMS_TX_WRITE) { - // TODO: need to tidy this piece up! - // a response from UBA after a write should be within a specific time period <100ms - /* - if (value == 0x01) { - emsaurt_tx_poll(); // send a poll acknowledgement - myDebug("Receiver of last write acknowledged OK, ready to validate...\n"); - EMS_TxTelegram.action = EMS_TX_VALIDATE; - } else if (value == 0x04) { - EMS_TxTelegram.action = EMS_TX_NONE; - myDebug("Return value from write FAILED.\n"); - emsaurt_tx_poll(); // send a poll acknowledgement + } else if ((value == EMS_TX_ERROR) || (value == EMS_TX_SUCCESS)) { + // if its a success (01) or failure (04), then see if its from one of our last writes + // a response from UBA after a write should be within a specific time period < 100ms + // TODO what we should really do here is just cancel the write operation + if (!EMS_TxQueue.isEmpty()) { + _EMS_TxTelegram EMS_TxTelegram = EMS_TxQueue.first(); // get current Tx package we last sent + if ((EMS_TxTelegram.action == EMS_TX_TELEGRAM_VALIDATE) && (value == EMS_TX_ERROR)) { + if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE) { + myDebug("** Error: last write failed. removing write op from queue!\n"); + } + EMS_TxQueue.shift(); // write failed so remove from queue. pretty sloppy. + } + emsaurt_tx_poll(); // send a poll to free the EMS bus } - */ } - return; // all done here, quit. if we haven't processes anything its a poll but not for us } // ignore anything that doesn't resemble a proper telegram package // minimal is 5 bytes, excluding CRC at the end - if (length < 5) { + /* + if (length < EMS_MIN_TELEGRAM_LENGTH - 1) { _debugPrintTelegram("Noisy data:", telegram, length, COLOR_RED); return; } + */ // Assume at this point we have something that vaguely resembles a telegram // see if we got a telegram as [src] [dest] [type] [offset] [data] [crc] @@ -409,77 +527,33 @@ void ems_parseTelegram(uint8_t * telegram, uint8_t length) { EMS_Sys_Status.emxCrcErr++; _debugPrintTelegram("Corrupt telegram:", telegram, length, COLOR_RED); } else { - // go and do the magic - _processType(telegram, length); - } -} - -/* - * decipher the telegram packet - * length is only data bytes, excluding the BRK - */ -void _processType(uint8_t * telegram, uint8_t length) { - // extract the 4-byte header information - // removing 8th bit as we deal with both reads and writes - uint8_t src = telegram[0] & 0x7F; - - // if its an echo of ourselves from the master, ignore - if (src == EMS_ID_ME) { - _debugPrintTelegram("Telegram echo:", telegram, length, COLOR_BLUE); - return; - } - - // header - uint8_t dest = telegram[1] & 0x7F; // remove 8th bit - uint8_t type = telegram[2]; - uint8_t * data = telegram + 4; // data block starts at position 5 - - // scan through known types we understand - // set typeFound if we found a match - int i = 0; - bool typeFound = false; - while (i < _EMS_Types_max) { - if ((EMS_Types[i].src == src) && (EMS_Types[i].type == type)) { - // we have a match - typeFound = true; - // call callback to fetch the values from the telegram - // data block is sent, which starts with the 5th byte of the telegram - // return value tells us if we need to force send values back to MQTT - if ((EMS_Types[i].processType_cb) != (void *)NULL) { - EMS_Sys_Status.emsRefreshed = EMS_Types[i].processType_cb(data, length); - } - - break; - } - i++; - } - - // did we actually ask for it from an earlier read/write request? - // note when we issue a read command the responder (dest) has to return a telegram back immediately - if (dest == EMS_ID_ME) { - // send Acknowledgement back to free the bus - emsaurt_tx_poll(); - - if ((EMS_TxTelegram.action == EMS_TX_READ) && (EMS_TxTelegram.type == type)) { - // yes we were expecting this one one - EMS_Sys_Status.emsRxPgks++; // increment rx counter - EMS_Sys_Status.emsLastRx = millis(); - EMS_TxTelegram.action = EMS_TX_NONE; - emsLastRxCount = 0; // reset retry count - } - } - - // print debug messages - // special case for only thermostat - if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_THERMOSTAT) { - if ((src == EMS_ID_THERMOSTAT) && (dest == EMS_ID_ME)) { - myDebug("Thermostat -> me, type 0x%02X telegram: ", type); + // if we in raw mode then just output the telegram + if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_RAW) { for (int i = 0; i < length; i++) { myDebug("%02X ", telegram[i]); } myDebug("\n"); } - } else if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE) { + + // here we know its a valid incoming telegram of at least 6 bytes + // lets process it and see what to do next + _processType(telegram, length); + } +} + +/** + * print detailed telegram + * and then call its callback if there is one defined + */ +void _ems_processTelegram(uint8_t * telegram, uint8_t length) { + // header + uint8_t src = telegram[0] & 0x7F; + uint8_t dest = telegram[1] & 0x7F; // remove 8th bit to handle both reads and writes + uint8_t type = telegram[2]; + uint8_t * data = telegram + 4; // data block starts at position 5 + + // print detailed telegram data + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_THERMOSTAT) { char color_s[20]; char src_s[20]; char dest_s[20]; @@ -513,75 +587,134 @@ void _processType(uint8_t * telegram, uint8_t length) { strcpy(color_s, COLOR_MAGENTA); } - - // and print sprintf(s, "%s%s, type 0x%02X", src_s, dest_s, type); - _debugPrintTelegram(s, telegram, length, color_s); - if (typeFound) { - myDebug("<--- %s(0x%02X) received\n", EMS_Types[i].typeString, type); - } - } + // and print telegram - // check to see if we're waiting on a specific value, either from a recent read or by chance a broadcast - // and then do the comparison - if ((EMS_TxTelegram.action == EMS_TX_VALIDATE) && (EMS_TxTelegram.type == type)) { - uint8_t offset; - - // we're waiting for a read on our last write to compare the values - // if we have a special telegram package we sent to validate use that for comparison, otherwise assume its a simple 1-byte read - if (EMS_TxTelegram.type_validate == EMS_ID_NONE) { - offset = EMS_TxTelegram.offset; // special, use the offset from the last send + if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_THERMOSTAT) { + // only print ones to/from thermostat if logging is set to thermostat only + if ((src == EMS_ID_THERMOSTAT) || (dest == EMS_ID_THERMOSTAT)) { + _debugPrintTelegram(s, telegram, length, color_s); + } } else { - offset = 0; + // allways print + _debugPrintTelegram(s, telegram, length, color_s); } + } - // get the data at the position we wrote to and compare - // when validating we always return a single value - if (EMS_TxTelegram.checkValue == data[offset]) { - // there is a match, so successful send - EMS_Sys_Status.emsRefreshed = true; // flag this so values are sent back to HA via MQTT - EMS_TxTelegram.action = EMS_TX_NONE; // no more sends - } - - // some debug messages - if (EMS_Sys_Status.emsLogging != EMS_SYS_LOGGING_NONE) { - // look up the ID and fetch string - int i = ems_findType(EMS_TxTelegram.type); - if (i != -1) { - myDebug("---> %s(0x%02X) sent with value %d at offset %d ", - EMS_Types[i].typeString, - type, - EMS_TxTelegram.checkValue, - offset); - } else { - myDebug("---> ?(0x%02X) sent with value %d at offset %d ", type, EMS_TxTelegram.checkValue, offset); - } - - if (EMS_TxTelegram.checkValue == data[offset]) { - myDebug("(successful)\n"); - } else { - myDebug("(failed, received %d)\n", data[offset]); + // try and match it against known types and call the call handler function + // only process telegrams broadcasting to everyone or sent to us specifically + if ((dest == EMS_ID_ME) || (dest == EMS_ID_NONE)) { + int i = 0; + bool typeFound = false; + while (i < _EMS_Types_max) { + if (((EMS_Types[i].src == src) || (EMS_Types[i].src == EMS_ID_NONE)) && (EMS_Types[i].type == type)) { + // we have a match + typeFound = true; + // call callback to fetch the values from the telegram + // data block is sent, which starts with the 5th byte of the telegram + // return value tells us if we need to force send values back to MQTT + // the length is the #bytes of the data (excluding the header and CRC) + if ((EMS_Types[i].processType_cb) != (void *)NULL) { + // print non-verbose message + if ((EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_BASIC) + || (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE)) { + myDebug("<--- %s(0x%02X) received\n", EMS_Types[i].typeString, type); + } + (void)EMS_Types[i].processType_cb(data, length - 5); + } + break; } + i++; } } } -/* - * Report back true if there is a package pending a write in the queue + +/** + * deciphers the telegram packet + * length is only data bytes, excluding the BRK + * We only remove from the Tx queue if the read or write was successful */ -bool _checkWriteQueueFull() { - if (EMS_Sys_Status.emsTxStatus == EMS_TX_PENDING) { // send is already pending - if (ems_getLogging() != EMS_SYS_LOGGING_NONE) { - myDebug("Delaying write command as there is already a telegram (type 0x%02X) in the queue\n", - EMS_TxTelegram.type); - } - return true; // something in queue +void _processType(uint8_t * telegram, uint8_t length) { + // header + uint8_t src = telegram[0] & 0x7F; // removing 8th bit as we deal with both reads and writes + uint8_t dest = telegram[1] & 0x7F; // remove 8th bit to handle both reads and writes + uint8_t type = telegram[2]; + uint8_t * data = telegram + 4; // data block starts at position 5 + + // if its an echo of ourselves from the master, ignore + if (src == EMS_ID_ME) { + // _debugPrintTelegram("Telegram echo:", telegram, length, COLOR_BLUE); + return; } - return false; // nothing queue, we can do a write command + // did we request this telegram? If so it would be a read or a validate telegram still on the Tx queue + // with the same type + // if its a validate check the value, or if its a read, update the Read counter + // then we can safely removed the read/validate from the queue + if ((dest == EMS_ID_ME) && (!EMS_TxQueue.isEmpty())) { + _EMS_TxTelegram EMS_TxTelegram = EMS_TxQueue.first(); // get current Tx package we last sent + + // do the types match? If so we were expecting this response back to us + if (EMS_TxTelegram.type == type) { + emsaurt_tx_poll(); // send Acknowledgement back to free the EMS bus + + // if last action was a read, where just happy that we got a response back + if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_READ) { + EMS_Sys_Status.emsRxPgks++; // increment rx counter + emsRxRetryCount = 0; // reset retry count + _ems_processTelegram(telegram, length); // and process it + if (EMS_TxTelegram.forceRefresh) { + ems_setEmsRefreshed(true); // set the MQTT refresh flag to force sending to MQTT + } + EMS_TxQueue.shift(); // remove read from queue + } else if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_VALIDATE) { + // this read was for a validate. Do a compare on the 1 byte result from the last write + uint8_t dataReceived = data[0]; // only a single byte is returned after a read + if (EMS_TxTelegram.comparisonValue == dataReceived) { + // there is a match, so write must have been successful + EMS_TxQueue.shift(); // remove validate from queue + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_BASIC) { + myDebug("Write to 0x%02X successful.\n", EMS_TxTelegram.dest); + } + ems_doReadCommand(EMS_TxTelegram.comparisonPostRead, + EMS_TxTelegram.dest, + true); // get values and force a refresh to MQTT + } else { + // write failed. + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_BASIC) { + myDebug("Last write failed. Compared set value 0x%02X with received value 0x%02X. ", + EMS_TxTelegram.comparisonValue, + dataReceived); + } + if (emsRxRetryCount++ >= RX_READ_TIMEOUT_COUNT) { + // give up + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_BASIC) { + myDebug("Giving up!\n"); + } + EMS_TxQueue.shift(); // remove from queue + } else { + // retry, turn the validate back into a write and try again + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_BASIC) { + myDebug("Retrying attempt %d...\n", emsRxRetryCount); + } + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; + EMS_TxTelegram.dataValue = EMS_TxTelegram.comparisonValue; // restore old value + EMS_TxTelegram.offset = EMS_TxTelegram.comparisonOffset; // restore old value + EMS_TxQueue.shift(); // remove validate from queue + EMS_TxQueue.unshift(EMS_TxTelegram); // add back to queue making it next in line + } + } + } + } + // telegram was for us, but seems we didn't ask for it + } else { + // we didn't request it + _ems_processTelegram(telegram, length); // and process and print it + } } -/* +/** * Check if hot tap water or heating is active * using a quick hack: * heating is on if Selected Flow Temp >= 70 (in my case) @@ -593,41 +726,41 @@ bool _checkActive() { ((EMS_Boiler.selFlowTemp == 0) && (EMS_Boiler.selBurnPow >= EMS_BOILER_BURNPOWER_TAPWATER) & (EMS_Boiler.burnGas == EMS_VALUE_INT_ON)); + // heating EMS_Boiler.heatingActive = ((EMS_Boiler.selFlowTemp >= EMS_BOILER_SELFLOWTEMP_HEATING) && (EMS_Boiler.burnGas == EMS_VALUE_INT_ON)); } -/* +/** * UBAParameterWW - type 0x33 - warm water parameters * received only after requested (not broadcasted) */ -bool _process_UBAParameterWW(uint8_t * data, uint8_t length) { - EMS_Boiler.wWSelTemp = data[2]; +void _process_UBAParameterWW(uint8_t * data, uint8_t length) { EMS_Boiler.wWActivated = (data[1] == 0xFF); // 0xFF means on + EMS_Boiler.wWSelTemp = data[2]; EMS_Boiler.wWCircPump = (data[6] == 0xFF); // 0xFF means on EMS_Boiler.wWDesiredTemp = data[8]; - return true; // triggers a send the values back to Home Assistant via MQTT + // when we receieve this, lets force an MQTT publish + EMS_Sys_Status.emsRefreshed = true; } -/* +/** * UBAMonitorWWMessage - type 0x34 - warm water monitor. 19 bytes long * received every 10 seconds */ -bool _process_UBAMonitorWWMessage(uint8_t * data, uint8_t length) { +void _process_UBAMonitorWWMessage(uint8_t * data, uint8_t length) { EMS_Boiler.wWCurTmp = _toFloat(1, data); EMS_Boiler.wWStarts = _toLong(13, data); EMS_Boiler.wWWorkM = _toLong(10, data); EMS_Boiler.wWOneTime = bitRead(data[5], 1); - - return false; // no need to update mqtt } -/* +/** * UBAMonitorFast - type 0x18 - central heating monitor part 1 (25 bytes long) * received every 10 seconds */ -bool _process_UBAMonitorFast(uint8_t * data, uint8_t length) { +void _process_UBAMonitorFast(uint8_t * data, uint8_t length) { EMS_Boiler.selFlowTemp = data[0]; EMS_Boiler.curFlowTemp = _toFloat(1, data); EMS_Boiler.retTemp = _toFloat(13, data); @@ -653,120 +786,124 @@ bool _process_UBAMonitorFast(uint8_t * data, uint8_t length) { // at this point do a quick check to see if the hot water or heating is active (void)_checkActive(); - - return false; // no need to update mqtt } -/* +/** * UBAMonitorSlow - type 0x19 - central heating monitor part 2 (27 bytes long) * received every 60 seconds */ -bool _process_UBAMonitorSlow(uint8_t * data, uint8_t length) { +void _process_UBAMonitorSlow(uint8_t * data, uint8_t length) { EMS_Boiler.extTemp = _toFloat(0, data); // 0x8000 if not available EMS_Boiler.boilTemp = _toFloat(2, data); // 0x8000 if not available EMS_Boiler.pumpMod = data[9]; EMS_Boiler.burnStarts = _toLong(10, data); EMS_Boiler.burnWorkMin = _toLong(13, data); EMS_Boiler.heatWorkMin = _toLong(19, data); - - return true; // triggers a send the values back to Home Assistant via MQTT } -/* +/** * RC20StatusMessage - type 0x91 - data from the RC20 thermostat (0x17) - 15 bytes long + * For reading the temp values only * received every 60 seconds */ -bool _process_RC20StatusMessage(uint8_t * data, uint8_t length) { +void _process_RC20StatusMessage(uint8_t * data, uint8_t length) { EMS_Thermostat.setpoint_roomTemp = ((float)data[1]) / (float)2; EMS_Thermostat.curr_roomTemp = _toFloat(2, data); - return true; // triggers a send the values back to Home Assistant via MQTT + EMS_Sys_Status.emsRefreshed = true; // triggers a send the values back to Home Assistant via MQTT } -/* - * EasyTemperature - type 0x0A - data from the Nefit Easy/TC100 thermostat (0x18) - 31 bytes long +/** + * RC30StatusMessage - type 0x41 - data from the RC30 thermostat (0x10) - 14 bytes long + * For reading the temp values only + * received every 60 seconds + */ +void _process_RC30StatusMessage(uint8_t * data, uint8_t length) { + EMS_Thermostat.setpoint_roomTemp = ((float)data[1]) / (float)2; + EMS_Thermostat.curr_roomTemp = _toFloat(2, data); + + EMS_Sys_Status.emsRefreshed = true; // triggers a send the values back to Home Assistant via MQTT +} + +/** + * EasyStatusMessage - type 0x0A - data from the Nefit Easy/TC100 thermostat (0x18) - 31 bytes long * The Easy has a digital precision of its floats to 2 decimal places, so values is divided by 100 */ -bool _process_EasyTemperature(uint8_t * data, uint8_t length) { +void _process_EasyStatusMessage(uint8_t * data, uint8_t length) { EMS_Thermostat.curr_roomTemp = ((float)(((data[8] << 8) + data[9]))) / 100; EMS_Thermostat.setpoint_roomTemp = ((float)(((data[10] << 8) + data[11]))) / 100; - return true; // triggers a send the values back to Home Assistant via MQTT + EMS_Sys_Status.emsRefreshed = true; // triggers a send the values back to Home Assistant via MQTT } -/* - * RC20Temperature - type 0xa8 - for set temp value and mode from the RC20 thermostat (0x17) +/** + * RC20Temperature - type 0xA8 - for reading the mode from the RC20 thermostat (0x17) * received only after requested */ -bool _process_RC20Temperature(uint8_t * data, uint8_t length) { - // check if this was called specifically to validate a single value - // which is always stored in data[0] because we request only 1 byte - if (length == EMS_MIN_TELEGRAM_LENGTH) { - if (EMS_TxTelegram.type_validate == EMS_OFFSET_RC20Temperature_temp) { - // validate the set temp - EMS_Thermostat.setpoint_roomTemp = ((float)data[0]) / (float)2; - } else if (EMS_TxTelegram.type_validate == EMS_OFFSET_RC20Temperature_mode) { - // validate the mode - EMS_Thermostat.mode = data[0]; - } - - // and send the values back to HA (Home Assistant MQTT) - return true; - } - - // Process the whole telegram package - EMS_Thermostat.mode = data[EMS_OFFSET_RC20Temperature_mode]; // get the mode - return false; // don't update mqtt +void _process_RC20Set(uint8_t * data, uint8_t length) { + EMS_Thermostat.mode = data[EMS_OFFSET_RC20Set_mode]; } -/* - * RC20OutdoorTempMessage - type 0xa3 - for external temp settings from the the RC* thermostats +/** + * RC30Temperature - type 0xA7 - for reading the mode from the RC30 thermostat (0x10) + * received only after requested */ -bool _process_RCTempMessage(uint8_t * data, uint8_t length) { +void _process_RC30Set(uint8_t * data, uint8_t length) { + EMS_Thermostat.mode = data[EMS_OFFSET_RC30Set_mode]; +} + +/** + * RCOutdoorTempMessage - type 0xA3 - for external temp settings from the the RC* thermostats + */ +void _process_RCOutdoorTempMessage(uint8_t * data, uint8_t length) { // add support here if you're reading external sensors - - return false; // don't update mqtt } - -/* - * Version - type 0x02 - get the version of the Thermostat firmware +/** + * Version - type 0x02 - get the firmware version and type of a EMS device (Boiler, Thermostat etc) * When a thermostat is connecting it will send out 0x02 messages too, which we'll ignore - * We don't bother storing these values anywhere, just print + * We don't bother storing these values anywhere, just print them for now + * Moduline 300, Type 77. Version 3.03 + * Moduline 400, Type 78, Version 3.03 + * Nefit Easy = Type 202. Version 2.19 + * Nefit Trendline HRC30 = Type 123. Version 6.1 */ -bool _process_Version(uint8_t * data, uint8_t length) { - // ignore short messages - if (length == 8) { +void _process_Version(uint8_t * data, uint8_t length) { + // ignore short messages that we can't interpret + if (length >= 3) { + uint8_t type = data[0]; uint8_t major = data[1]; uint8_t minor = data[2]; - if (EMS_Sys_Status.emsLogging != EMS_SYS_LOGGING_NONE) { - myDebug("Version %d.%d\n", major, minor); + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_BASIC) { + myDebug("Product ID %d. Version %02d.%02d\n", type, major, minor); } } - - return false; // don't update mqtt } -/* - * UBASetPoint 0x1A +/** + * UBASetPoint 0x1A, for RC20 */ -bool _process_SetPoints(uint8_t * data, uint8_t length) { - uint8_t setpoint = data[0]; - uint8_t hk_power = data[1]; - uint8_t ww_power = data[2]; +void _process_SetPoints(uint8_t * data, uint8_t length) { + if (EMS_Sys_Status.emsLogging >= EMS_SYS_LOGGING_THERMOSTAT) { + if (length != 0) { + uint8_t setpoint = data[0]; + uint8_t hk_power = data[1]; + uint8_t ww_power = data[2]; + myDebug(" SetPoint=%d, hk_power=%d ww_power=%d\n", setpoint, hk_power, ww_power); + } + myDebug("\n"); + } +} - if (EMS_Sys_Status.emsLogging == EMS_SYS_LOGGING_VERBOSE) { - myDebug("UBASetPoint: SetPoint=%d, hk_power=%d ww_power=%d\n", setpoint, hk_power, ww_power); +/** + * process_RCTime - type 0x06 - date and time from a thermostat - 14 bytes long + * common for all thermostats + */ +void _process_RCTime(uint8_t * data, uint8_t length) { + if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_EASY) { + return; // not supported } - return false; -} - - -/* - * process_RCTime - type 0x06 - date and time from a thermostat - 14 bytes long - */ -bool _process_RCTime(uint8_t * data, uint8_t length) { EMS_Thermostat.hour = data[2]; EMS_Thermostat.minute = data[4]; EMS_Thermostat.second = data[5]; @@ -774,8 +911,7 @@ bool _process_RCTime(uint8_t * data, uint8_t length) { EMS_Thermostat.month = data[1]; EMS_Thermostat.year = data[0]; - // we can optional set the time based on the thermostat's time if we want - // commented out because we use NTP to get time + // we can optional set the time based on the thermostat's time if we want. /* setTime(EMS_Thermostat.hour, EMS_Thermostat.minute, @@ -784,56 +920,123 @@ bool _process_RCTime(uint8_t * data, uint8_t length) { EMS_Thermostat.month, EMS_Thermostat.year + 2000); */ - - return false; // don't update mqtt } -/* - * Build the telegram, which includes a single byte followed by the CRC at the end +/** + * Print the Tx queue - for debugging */ -void _buildTxTelegram(uint8_t data_value) { - // header - EMS_TxTelegram.data[0] = EMS_ID_ME; // src - EMS_TxTelegram.data[1] = EMS_TxTelegram.dest; // dest - EMS_TxTelegram.data[2] = EMS_TxTelegram.type; // type - EMS_TxTelegram.data[3] = EMS_TxTelegram.offset; //offset +void ems_printTxQueue() { + _EMS_TxTelegram EMS_TxTelegram; + char sType[20]; - // data: - // for reading this is #bytes we want to read (the size) - // for writing its the value we want to write - EMS_TxTelegram.data[4] = data_value; + myDebug("Tx queue (%d/%d)\n", EMS_TxQueue.size(), EMS_TxQueue.capacity); - // crc: - EMS_TxTelegram.data[5] = _crcCalculator(EMS_TxTelegram.data, EMS_TxTelegram.length); + for (byte i = 0; i < EMS_TxQueue.size(); i++) { + EMS_TxTelegram = EMS_TxQueue[i]; // retrieves the i-th element from the buffer without removing it - EMS_Sys_Status.emsTxStatus = EMS_TX_PENDING; // armed and ready to send -} + // get action + if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_WRITE) { + strcpy(sType, "write"); + } else if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_READ) { + strcpy(sType, "read"); + } else if (EMS_TxTelegram.action == EMS_TX_TELEGRAM_VALIDATE) { + strcpy(sType, "validate"); + } else { + strcpy(sType, "?"); + } -/* - * Generic function to return temperature settings from the thermostat - */ -void ems_getThermostatTemps() { - if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_RC20) { - ems_doReadCommand(EMS_TYPE_RC20Temperature, EMS_ID_THERMOSTAT); - } else if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_EASY) { - ems_doReadCommand(EMS_TYPE_EasyTemperature, EMS_ID_THERMOSTAT); + myDebug(" [%d] action=%s dest=0x%02x type=0x%02x offset=%d length=%d dataValue=%d " + "comparisonValue=%d hasSent=%d, type_validate=0x%02x comparisonPostRead=0x%02x\n", + i, + sType, + EMS_TxTelegram.dest & 0x7F, + EMS_TxTelegram.type, + EMS_TxTelegram.offset, + EMS_TxTelegram.length, + EMS_TxTelegram.dataValue, + EMS_TxTelegram.comparisonValue, + EMS_TxTelegram.hasSent, + EMS_TxTelegram.type_validate, + EMS_TxTelegram.comparisonPostRead); } } -/* +/** + * Generic function to return temperature settings from the thermostat + * Supports RC20, RC30 and Easy + */ +void ems_getThermostatValues() { + if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_RC20) { + ems_doReadCommand(EMS_TYPE_RC20StatusMessage, EMS_ID_THERMOSTAT); // to get the setpoint temp + ems_doReadCommand(EMS_TYPE_RC20Set, EMS_ID_THERMOSTAT); // to get the mode + } else if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_RC30) { + ems_doReadCommand(EMS_TYPE_RC30StatusMessage, EMS_ID_THERMOSTAT); // to get the setpoint temp + ems_doReadCommand(EMS_TYPE_RC30Set, EMS_ID_THERMOSTAT); // to get the mode + } else if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_EASY) { + ems_doReadCommand(EMS_TYPE_EasyStatusMessage, EMS_ID_THERMOSTAT); + } +} + +/** + * print out current thermostat type + */ +void ems_printThermostatType() { + int i = 0; + bool typeFound = false; + while (i < _Thermostat_Types_max) { + if (Thermostat_Types[i].id == EMS_ID_THERMOSTAT) { + typeFound = true; // we have a match + break; + } + i++; + } + if (typeFound) { + myDebug("%s [ID 0x%02X]", Thermostat_Types[i].typeString, Thermostat_Types[i].id); + } else { + myDebug("Unknown? [ID 0x%02X]", Thermostat_Types[i].id); + } +} + +/** + * Print out all handled types + */ +void ems_printAllTypes() { + myDebug("These %d telegram type IDs are recognized:\n", _EMS_Types_max); + uint8_t i; + char s[20]; + + for (i = 0; i < _EMS_Types_max; i++) { + if (EMS_Types[i].src == EMS_ID_THERMOSTAT) { + strcpy(s, "Thermostat"); + } else if (EMS_Types[i].src == EMS_ID_BOILER) { + strcpy(s, "Boiler"); + } else { + strcpy(s, "Common"); + } + myDebug(" %s:\ttype ID %02X (%s)\n", s, EMS_Types[i].type, EMS_Types[i].typeString); + } + + myDebug("\nThese %d telegram Thermostats are natively supported:\n", _Thermostat_Types_max); + + for (i = 0; i < _Thermostat_Types_max; i++) { + myDebug(" %s [ID 0x%02X]\n", Thermostat_Types[i].typeString, Thermostat_Types[i].id); + } +} + +/** * Send a command to UART Tx to Read from another device * Read commands when sent must respond by the destination (target) immediately (or within 10ms) */ -void ems_doReadCommand(uint8_t type, uint8_t dest) { - if (type == EMS_TYPE_NONE) - return; // not a valid type, quit +void ems_doReadCommand(uint8_t type, uint8_t dest, bool forceRefresh) { + // if not a valid type of boiler is not accessible then quit + if ((type == EMS_ID_NONE) || (!EMS_Sys_Status.emsBoilerEnabled)) { + return; + } - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx // see if its a known type - int i = ems_findType(type); - // uint8_t dest = (i == -1 ? EMS_ID_BOILER : EMS_Types[i].src); // default is Boiler + int i = _ems_findType(type); if ((ems_getLogging() == EMS_SYS_LOGGING_BASIC) || (ems_getLogging() == EMS_SYS_LOGGING_VERBOSE)) { if (i == -1) { @@ -842,138 +1045,255 @@ void ems_doReadCommand(uint8_t type, uint8_t dest) { myDebug("Requesting type %s(0x%02X) from dest 0x%02X\n", EMS_Types[i].typeString, type, dest); } } + EMS_TxTelegram.action = EMS_TX_TELEGRAM_READ; // read command + EMS_TxTelegram.dest = dest; // set 8th bit to indicate a read + EMS_TxTelegram.offset = 0; // 0 for all data + EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; // is always 6 bytes long (including CRC at end) + EMS_TxTelegram.type = type; + EMS_TxTelegram.dataValue = EMS_MAX_TELEGRAM_LENGTH; // for a read this is the # bytes we want back + EMS_TxTelegram.type_validate = EMS_ID_NONE; + EMS_TxTelegram.comparisonValue = 0; + EMS_TxTelegram.comparisonOffset = 0; + EMS_TxTelegram.comparisonPostRead = EMS_ID_NONE; + EMS_TxTelegram.forceRefresh = forceRefresh; // should we send to MQTT after a successful read? - EMS_TxTelegram.action = EMS_TX_READ; // read command - EMS_TxTelegram.dest = dest | 0x80; // set 7th bit to indicate a read - EMS_TxTelegram.offset = 0; // 0 for all data - EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; // is always 6 bytes long (including CRC at end) - EMS_TxTelegram.type = type; - - _buildTxTelegram(EMS_MAX_TELEGRAM_LENGTH); // we send the # bytes we want back + EMS_TxQueue.push(EMS_TxTelegram); } -/* +/** + * Send a raw telegram to the bus + */ +void ems_sendRawTelegram(char * telegram) { + uint8_t count = 0; + char * p, value[10]; + + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx + + // get first value, which should be the src + if (p = strtok(telegram, " ,")) { // delimiter + strcpy(value, p); + EMS_TxTelegram.data[0] = (uint8_t)strtol(value, 0, 16); + } + // and interate until end + while (p != 0) { + if (p = strtok(NULL, " ,")) { + strcpy(value, p); + uint8_t val = (uint8_t)strtol(value, 0, 16); + EMS_TxTelegram.data[++count] = val; + if (count == 1) { + EMS_TxTelegram.dest = val; + } else if (count == 2) { + EMS_TxTelegram.type = val; + } else if (count == 3) { + EMS_TxTelegram.offset = val; + } + } + } + + // calculate length including header and CRC + EMS_TxTelegram.length = count + 2; + EMS_TxTelegram.type_validate = EMS_ID_NONE; + EMS_TxTelegram.action = EMS_TX_TELEGRAM_RAW; + + // add to Tx queue. Assume it's not full. + EMS_TxQueue.push(EMS_TxTelegram); +} + +/** * Set the temperature of the thermostat */ void ems_setThermostatTemp(float temperature) { - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx - EMS_TxTelegram.action = EMS_TX_WRITE; + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; EMS_TxTelegram.dest = EMS_ID_THERMOSTAT; + myDebug("Setting new thermostat temperature\n"); + if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_RC20) { - myDebug("Setting new thermostat temperature\n"); + EMS_TxTelegram.type = EMS_TYPE_RC20Set; + EMS_TxTelegram.offset = EMS_OFFSET_RC20Set_temp; + EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; + EMS_TxTelegram.dataValue = (uint8_t)((float)temperature * (float)2); // value - // RC20 - EMS_TxTelegram.type = EMS_TYPE_RC20Temperature; - EMS_TxTelegram.offset = EMS_OFFSET_RC20Temperature_temp; - EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; - EMS_TxTelegram.checkValue = - (uint8_t)((float)temperature * (float)2); // value to compare against. must be a single int + EMS_TxTelegram.type_validate = EMS_TxTelegram.type; + EMS_TxTelegram.comparisonOffset = EMS_TxTelegram.offset; + EMS_TxTelegram.comparisonValue = EMS_TxTelegram.dataValue; + EMS_TxTelegram.comparisonPostRead = + EMS_TYPE_RC20StatusMessage; // call a different type to refresh temperature value - // post call is back to EMS_TYPE_RC20Temperature to fetch temps and send to HA - EMS_TxTelegram.type_validate = EMS_OFFSET_RC20Temperature_temp; + } else if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_RC30) { + EMS_TxTelegram.type = EMS_TYPE_RC30Set; + EMS_TxTelegram.offset = EMS_OFFSET_RC30Set_temp; + EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; + EMS_TxTelegram.dataValue = (uint8_t)((float)temperature * (float)2); // value + EMS_TxTelegram.type_validate = EMS_TxTelegram.type; + EMS_TxTelegram.comparisonOffset = EMS_TxTelegram.offset; + EMS_TxTelegram.comparisonValue = EMS_TxTelegram.dataValue; + EMS_TxTelegram.comparisonPostRead = + EMS_TYPE_RC30StatusMessage; // call a different type to refresh temperature value } else if (EMS_Thermostat.type == EMS_ID_THERMOSTAT_EASY) { - myDebug("Setting new thermostat temperature on an Easy - not working\n"); - - EMS_TxTelegram.type = EMS_TYPE_EasyTemperature; - EMS_TxTelegram.offset = 11; - EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; - EMS_TxTelegram.checkValue = 0; - - EMS_TxTelegram.type_validate = EMS_ID_NONE; + myDebug("Setting new thermostat temperature on an Easy (not working yet!)\n"); + return; } - _buildTxTelegram(EMS_TxTelegram.checkValue); + EMS_TxTelegram.forceRefresh = false; // send to MQTT is done automatically in EMS_TYPE_RC30StatusMessage + EMS_TxQueue.push(EMS_TxTelegram); } -/* - * Set the thermostat working mode (0=low, 1=manual, 2=auto) +/** + * Set the thermostat working mode (0=low, 1=manual, 2=auto/clock) + * 0xA8 on a RC20 and 0xA7 on RC30 */ void ems_setThermostatMode(uint8_t mode) { - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue + if (EMS_ID_THERMOSTAT == EMS_ID_THERMOSTAT_EASY) { + // doesn't support Easy yet + return; + } myDebug("Setting thermostat mode to %d\n", mode); - EMS_TxTelegram.action = EMS_TX_WRITE; - EMS_TxTelegram.dest = EMS_ID_THERMOSTAT; - EMS_TxTelegram.type = EMS_TYPE_RC20Temperature; - EMS_TxTelegram.offset = EMS_OFFSET_RC20Temperature_mode; - EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; - EMS_TxTelegram.checkValue = mode; // value to compare against. must be a single int + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx - // post call is back to EMS_TYPE_RC20Temperature to fetch temps and send to HA - EMS_TxTelegram.type_validate = EMS_OFFSET_RC20Temperature_mode; - _buildTxTelegram(EMS_TxTelegram.checkValue); + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; + EMS_TxTelegram.dest = EMS_ID_THERMOSTAT; + EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; + EMS_TxTelegram.dataValue = mode; + + // handle different thermostat types + if (EMS_ID_THERMOSTAT == EMS_ID_THERMOSTAT_RC20) { + EMS_TxTelegram.type = EMS_TYPE_RC20Set; + EMS_TxTelegram.offset = EMS_OFFSET_RC20Set_mode; + } else if (EMS_ID_THERMOSTAT == EMS_ID_THERMOSTAT_RC30) { + EMS_TxTelegram.type = EMS_TYPE_RC30Set; + EMS_TxTelegram.offset = EMS_OFFSET_RC30Set_mode; + } else { + myDebug("Error! not supported\n"); + return; + } + + EMS_TxTelegram.type_validate = EMS_TxTelegram.type; // callback to EMS_TYPE_RC30Temperature to fetch temps + EMS_TxTelegram.comparisonOffset = EMS_TxTelegram.offset; + EMS_TxTelegram.comparisonValue = EMS_TxTelegram.dataValue; + EMS_TxTelegram.comparisonPostRead = EMS_TxTelegram.type; + EMS_TxTelegram.forceRefresh = false; // send to MQTT is done automatically in 0xA8 process + + EMS_TxQueue.push(EMS_TxTelegram); } -/* - * Set the warm water temperature +/** + * Set the warm water temperature 0x33 */ void ems_setWarmWaterTemp(uint8_t temperature) { - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue + // check for invalid temp values + if ((temperature < 30) || (temperature > 90)) { + return; + } myDebug("Setting boiler warm water temperature to %d C\n", temperature); - EMS_TxTelegram.action = EMS_TX_WRITE; - EMS_TxTelegram.dest = EMS_ID_BOILER; - EMS_TxTelegram.type = EMS_TYPE_UBAParameterWW; - EMS_TxTelegram.offset = EMS_OFFSET_UBAParameterWW_wwtemp; - EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; - EMS_TxTelegram.checkValue = temperature; // value to compare against. must be a single int - EMS_TxTelegram.type_validate = EMS_ID_NONE; // don't force a send to check the value but do it during next broadcast + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx - _buildTxTelegram(temperature); + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; + EMS_TxTelegram.dest = EMS_ID_BOILER; + EMS_TxTelegram.type = EMS_TYPE_UBAParameterWW; + EMS_TxTelegram.offset = EMS_OFFSET_UBAParameterWW_wwtemp; + EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; + EMS_TxTelegram.dataValue = temperature; // value to compare against. must be a single int + + EMS_TxTelegram.type_validate = EMS_TYPE_UBAParameterWW; // validate + EMS_TxTelegram.comparisonOffset = EMS_OFFSET_UBAParameterWW_wwtemp; + EMS_TxTelegram.comparisonValue = temperature; + EMS_TxTelegram.comparisonPostRead = EMS_TYPE_UBAParameterWW; + EMS_TxTelegram.forceRefresh = false; // no need to send since this is done by 0x33 process + + EMS_TxQueue.push(EMS_TxTelegram); } -/* - * Activate / De-activate the Warm Water +/** + * Activate / De-activate the Warm Water 0x33 * true = on, false = off */ void ems_setWarmWaterActivated(bool activated) { - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue + myDebug("Setting boiler warm water %s\n", activated ? "on" : "off"); - myDebug("Setting boiler warm water to %s\n", activated ? "on" : "off"); + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx - EMS_TxTelegram.action = EMS_TX_WRITE; + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; EMS_TxTelegram.dest = EMS_ID_BOILER; EMS_TxTelegram.type = EMS_TYPE_UBAParameterWW; EMS_TxTelegram.offset = EMS_OFFSET_UBAParameterWW_wwactivated; EMS_TxTelegram.length = EMS_MIN_TELEGRAM_LENGTH; - EMS_TxTelegram.type_validate = EMS_ID_NONE; // don't force a send to check the value but do it during next broadcast - - // 0xFF is on, 0x00 is off - EMS_TxTelegram.checkValue = (activated ? 0xFF : 0x00); - _buildTxTelegram(EMS_TxTelegram.checkValue); + EMS_TxTelegram.type_validate = EMS_ID_NONE; // don't validate + EMS_TxTelegram.dataValue = (activated ? 0xFF : 0x00); // 0xFF is on, 0x00 is off + EMS_TxQueue.push(EMS_TxTelegram); } -/* - * experimental code for debugging - use with caution +/** + * Activate / De-activate the Warm Tap Water + * true = on, false = off + * Using the type 0x1D to put the boiler into Test mode. This may be shown on the boiler with a flashing 'T' */ -void ems_setExperimental(uint8_t value) { - if (_checkWriteQueueFull()) - return; // check if there is already something in the queue +void ems_setWarmTapWaterActivated(bool activated) { + myDebug("Setting boiler warm tap water %s\n", activated ? "on" : "off"); - /* - EMS_TxTelegram.action = EMS_TX_READ; // read command - EMS_TxTelegram.dest = EMS_ID_THERMOSTAT | 0x80; // set 7th bit to indicate a read - EMS_TxTelegram.offset = 0; // 0 for all data - EMS_TxTelegram.length = 8; - EMS_TxTelegram.type = 0xF0; + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx + // clear Tx to make sure all data is set to 0x00 + for (int i = 0; (i < EMS_TX_MAXBUFFERSIZE); i++) { + EMS_TxTelegram.data[i] = 0x00; + } + + EMS_TxTelegram.action = EMS_TX_TELEGRAM_WRITE; + EMS_TxTelegram.dest = EMS_ID_BOILER; + EMS_TxTelegram.type = EMS_TYPE_UBAFunctionTest; + EMS_TxTelegram.offset = 0; + EMS_TxTelegram.length = 22; // 17 bytes of data including header and CRC + + EMS_TxTelegram.type_validate = EMS_TxTelegram.type; + EMS_TxTelegram.comparisonOffset = 0; // 1st byte + EMS_TxTelegram.comparisonValue = (activated ? 0 : 1); // value is 1 if in Test mode (not activated) + EMS_TxTelegram.comparisonPostRead = EMS_TxTelegram.type; + EMS_TxTelegram.forceRefresh = true; // send new value to MQTT after successful write + + + // create header EMS_TxTelegram.data[0] = EMS_ID_ME; // src EMS_TxTelegram.data[1] = EMS_TxTelegram.dest; // dest EMS_TxTelegram.data[2] = EMS_TxTelegram.type; // type - EMS_TxTelegram.data[3] = EMS_TxTelegram.offset; //offset + EMS_TxTelegram.data[3] = EMS_TxTelegram.offset; // offset + + // we use the special test mode 0x1D for this. Setting the first data to 5A puts the system into test mode and + // a setting of 0x00 puts it back into normal operarting mode + // when in test mode we're able to mess around with the core 3-way valve settings + if (!activated) { + // on + EMS_TxTelegram.data[4] = 0x5A; // test mode on + EMS_TxTelegram.data[5] = 0x00; // burner output 0% + EMS_TxTelegram.data[7] = 0x64; // boiler pump capacity 100% + EMS_TxTelegram.data[8] = 0xFF; // 3-way valve hot water only + } + + EMS_TxQueue.push(EMS_TxTelegram); // add to queue +} + +/** + * experimental code for debugging - not in production + */ +void ems_setExperimental(uint8_t value) { + /* + _EMS_TxTelegram EMS_TxTelegram = EMS_TX_TELEGRAM_NEW; // create new Tx + + EMS_TxTelegram.action = EMS_TX_TELEGRAM_READ; // read command + EMS_TxTelegram.dest = EMS_ID_THERMOSTAT; // set 8th bit to indicate a read + EMS_TxTelegram.offset = 0; // 0 for all data + EMS_TxTelegram.length = 8; + EMS_TxTelegram.type = 0xF0; + EMS_TxTelegram.type_validate = EMS_ID_NONE; // EMS Plus test // Sending read to 0x18: telegram: 0B 98 F0 00 01 B9 63 DB (len 8) - EMS_TxTelegram.data[0] = EMS_ID_ME; // src EMS_TxTelegram.data[1] = EMS_TxTelegram.dest; // dest EMS_TxTelegram.data[2] = 0xF0; // marker @@ -981,12 +1301,8 @@ void ems_setExperimental(uint8_t value) { EMS_TxTelegram.data[4] = 0x01; // hi byte EMS_TxTelegram.data[5] = 0xB9; // low byte - // data: EMS_TxTelegram.data[6] = 99; // max length - // crc: - EMS_TxTelegram.data[7] = _crcCalculator(EMS_TxTelegram.data, EMS_TxTelegram.length); - - EMS_Sys_Status.emsTxStatus = EMS_TX_PENDING; // armed and ready to send + EMS_TxQueue.push(EMS_TxTelegram); */ } diff --git a/src/ems.h b/src/ems.h index 5a2088a46..bce7ae158 100644 --- a/src/ems.h +++ b/src/ems.h @@ -9,26 +9,27 @@ #include // EMS IDs -#define EMS_ID_NONE 0x00 // Fixed - used as a dest in broadcast messages +#define EMS_ID_NONE 0x00 // Fixed - used as a dest in broadcast messages and empty type IDs #define EMS_ID_BOILER 0x08 // Fixed - also known as MC10. #define EMS_ID_ME 0x0B // Fixed - our device, hardcoded as "Service Key" -// Special EMS Telegram Types -#define EMS_TYPE_NONE 0x00 // none - #define EMS_MIN_TELEGRAM_LENGTH 6 // minimal length for a validation telegram, including CRC #define EMS_MAX_TELEGRAM_LENGTH 99 // max length of a telegram, including CRC #define EMS_TX_MAXBUFFERSIZE 128 // max size of the buffer. packets are 32 bits -#define EMS_ID_THERMOSTAT_RC20 0x17 // RC20 (older Moduline 300) -#define EMS_ID_THERMOSTAT_RC30 0x10 // RC30 (Moduline 300) -#define EMS_ID_THERMOSTAT_RC35 0x10 // RC35 (Moduline 400) -#define EMS_ID_THERMOSTAT_EASY 0x18 // Nefit Easy +#define EMS_ID_THERMOSTAT_RC20 0x17 // RC20 (e.g. Moduline 300) +#define EMS_ID_THERMOSTAT_RC30 0x10 // RC30 (e.g. Moduline 400) +#define EMS_ID_THERMOSTAT_EASY 0x18 // TC100 (Nefit Easy) // define here the EMS telegram types you need -// Boiler... +// Common for all EMS devices +#define EMS_TYPE_Version 0x02 // version of the UBA controller (boiler) + +/* + * Boiler... + */ #define EMS_TYPE_UBAMonitorFast 0x18 // is an automatic monitor broadcast #define EMS_TYPE_UBAMonitorSlow 0x19 // is an automatic monitor broadcast #define EMS_TYPE_UBAMonitorWWMessage 0x34 // is an automatic monitor broadcast @@ -38,26 +39,39 @@ #define EMS_TYPE_UBAMaintenanceSettingsMessage 0x15 #define EMS_TYPE_UBAParametersMessage 0x16 #define EMS_TYPE_UBASetPoints 0x1A +#define EMS_TYPE_UBAFunctionTest 0x1D -// Thermostat... -#define EMS_TYPE_RC20StatusMessage 0x91 // is an automatic thermostat broadcast -#define EMS_TYPE_RCTime 0x06 // is an automatic thermostat broadcast -#define EMS_TYPE_RCTempMessage 0xA3 // is an automatic thermostat broadcast -#define EMS_TYPE_RC20Temperature 0xA8 -#define EMS_TYPE_EasyTemperature 0x0A // reading values on an Easy Thermostat -#define EMS_TYPE_Version 0x02 // version of the UBA controller (boiler) +#define EMS_OFFSET_UBAParameterWW_wwtemp 2 // WW Temperature +#define EMS_OFFSET_UBAParameterWW_wwactivated 1 // WW Activated -// Offsets for specific values in a telegram, per type, used for validation -#define EMS_OFFSET_RC20Temperature_temp 0x1C // thermostat set temp -#define EMS_OFFSET_RC20Temperature_mode 0x17 // thermostat mode -#define EMS_OFFSET_UBAParameterWW_wwtemp 0x02 // WW Temperature -#define EMS_OFFSET_UBAParameterWW_wwactivated 0x01 // WW Activated +/* + * Thermostat... + */ + +// Common for all thermostats +#define EMS_TYPE_RCTime 0x06 // is an automatic thermostat broadcast +#define EMS_TYPE_RCOutdoorTempMessage 0xA3 // is an automatic thermostat broadcast, outdoor external temp + +// RC20 specific +#define EMS_TYPE_RC20StatusMessage 0x91 // is an automatic thermostat broadcast giving us temps +#define EMS_TYPE_RC20Set 0xA8 // for setting values like temp and mode +#define EMS_OFFSET_RC20Set_mode 23 // position of thermostat mode +#define EMS_OFFSET_RC20Set_temp 28 // position of thermostat setpoint temperature + +// RC30 specific +#define EMS_TYPE_RC30StatusMessage 0x41 // is an automatic thermostat broadcast giving us temps +#define EMS_TYPE_RC30Set 0xA7 // for setting values like temp and mode +#define EMS_OFFSET_RC30Set_mode 23 // position of thermostat mode +#define EMS_OFFSET_RC30Set_temp 28 // position of thermostat setpoint temperature + +// Easy specific +#define EMS_TYPE_EasyStatusMessage 0x0A // reading values on an Easy Thermostat // default values -#define EMS_VALUE_INT_ON 1 // boolean true -#define EMS_VALUE_INT_OFF 0 // boolean false -#define EMS_VALUE_INT_NOTSET 0xFF // for 8-bit ints -#define EMS_VALUE_FLOAT_NOTSET -1 // float unset +#define EMS_VALUE_INT_ON 1 // boolean true +#define EMS_VALUE_INT_OFF 0 // boolean false +#define EMS_VALUE_INT_NOTSET 0xFF // for 8-bit ints +#define EMS_VALUE_FLOAT_NOTSET -255 // float unset /* EMS UART transfer status */ typedef enum { @@ -67,20 +81,23 @@ typedef enum { typedef enum { EMS_TX_IDLE, - EMS_TX_PENDING, // got Tx package to send, waiting for next Poll to send - EMS_TX_ACTIVE // Tx package being sent, no break sent + EMS_TX_ACTIVE, // Tx package being sent, no break sent + EMS_TX_SUCCESS, + EMS_TX_ERROR } _EMS_TX_STATUS; typedef enum { - EMS_TX_NONE, - EMS_TX_READ, // doing a read request - EMS_TX_WRITE, // doing a write request - EMS_TX_VALIDATE // do a validate after a write -} _EMS_TX_ACTION; + EMS_TX_TELEGRAM_INIT, // just initialized + EMS_TX_TELEGRAM_READ, // doing a read request + EMS_TX_TELEGRAM_WRITE, // doing a write request + EMS_TX_TELEGRAM_VALIDATE, // do a read but only to validate the last write + EMS_TX_TELEGRAM_RAW // sending in raw mode +} _EMS_TX_TELEGRAM_ACTION; /* EMS logging */ typedef enum { EMS_SYS_LOGGING_NONE, // no messages + EMS_SYS_LOGGING_RAW, // raw data mode EMS_SYS_LOGGING_BASIC, // only basic read/write messages EMS_SYS_LOGGING_THERMOSTAT, // only telegrams sent from thermostat EMS_SYS_LOGGING_VERBOSE // everything @@ -94,31 +111,51 @@ typedef struct { uint16_t emsTxPkgs; // sent uint16_t emxCrcErr; // CRC errors bool emsPollEnabled; // flag enable the response to poll messages + bool emsTxEnabled; // flag if we're allowing sending of Tx packages bool emsThermostatEnabled; // if there is a RCxx thermostat active + bool emsBoilerEnabled; // is the boiler online _EMS_SYS_LOGGING emsLogging; // logging - unsigned long emsLastPoll; // in ms, last time we received a poll - unsigned long emsLastRx; // timings - unsigned long emsLastTx; // timings bool emsRefreshed; // fresh data, needs to be pushed out to MQTT } _EMS_Sys_Status; // The Tx send package typedef struct { - _EMS_TX_ACTION action; // read or write - uint8_t dest; - uint8_t type; - uint8_t offset; - uint8_t length; - uint8_t checkValue; // value to validate against - uint8_t type_validate; // type to call after a successful Write command - uint8_t data[EMS_TX_MAXBUFFERSIZE]; + _EMS_TX_TELEGRAM_ACTION action; // read or write + uint8_t dest; + uint8_t type; + uint8_t offset; + uint8_t length; + uint8_t dataValue; // value to validate against + uint8_t type_validate; // type to call after a successful Write command + uint8_t comparisonValue; // value to compare against during a validate + uint8_t comparisonOffset; // offset of where the byte is we want to compare too later + uint8_t comparisonPostRead; // after a successful write call this to read + bool hasSent; // has been sent, just pending ack + bool forceRefresh; // should we send to MQTT after a successful Tx? + uint8_t data[EMS_TX_MAXBUFFERSIZE]; } _EMS_TxTelegram; +// default empty Tx +const _EMS_TxTelegram EMS_TX_TELEGRAM_NEW = { + EMS_TX_TELEGRAM_INIT, // action + EMS_ID_NONE, // dest + EMS_ID_NONE, // type + 0, // offset + 0, // length + 0, // data value + EMS_ID_NONE, // type_validate + 0, // comparisonValue + 0, // comparisonOffset + EMS_ID_NONE, // comparisonPostRead + false, // hasSent + false, // forceRefresh + {0x00} // data +}; + /* * Telegram package defintions */ -typedef struct { - // UBAParameterWW +typedef struct { // UBAParameterWW uint8_t wWActivated; // Warm Water activated uint8_t wWSelTemp; // Warm Water selected temperature uint8_t wWCircPump; // Warm Water circulation pump Available @@ -161,7 +198,7 @@ typedef struct { // Thermostat data typedef struct { - uint8_t type; // thermostat type (RC20, RC30, RC35 etc) + uint8_t type; // thermostat type (RC30, Easy etc) float setpoint_roomTemp; // current set temp float curr_roomTemp; // current room temp uint8_t mode; // 0=low, 1=manual, 2=auto @@ -174,9 +211,9 @@ typedef struct { } _EMS_Thermostat; // call back function signature -typedef bool (*EMS_processType_cb)(uint8_t * data, uint8_t length); +typedef void (*EMS_processType_cb)(uint8_t * data, uint8_t length); -// Definition for each type, including the relative callback function +// Definition for each EMS type, including the relative callback function typedef struct { uint8_t src; uint8_t type; @@ -184,6 +221,12 @@ typedef struct { EMS_processType_cb processType_cb; } _EMS_Types; +// Definition for thermostat type +typedef struct { + uint8_t id; + const char typeString[50]; +} _Thermostat_Types; + // ANSI Colors #define COLOR_RESET "\x1B[0m" #define COLOR_BLACK "\x1B[0;30m" @@ -200,34 +243,42 @@ typedef struct { // function definitions extern void ems_parseTelegram(uint8_t * telegram, uint8_t len); void ems_init(); -void ems_doReadCommand(uint8_t type, uint8_t dest); +void ems_doReadCommand(uint8_t type, uint8_t dest, bool forceRefresh = false); +void ems_sendRawTelegram(char * telegram); void ems_setThermostatTemp(float temp); void ems_setThermostatMode(uint8_t mode); void ems_setWarmWaterTemp(uint8_t temperature); void ems_setWarmWaterActivated(bool activated); +void ems_setWarmTapWaterActivated(bool activated); void ems_setExperimental(uint8_t value); void ems_setPoll(bool b); +void ems_setTxEnabled(bool b); void ems_setThermostatEnabled(bool b); void ems_setLogging(_EMS_SYS_LOGGING loglevel); +void ems_setEmsRefreshed(bool b); -void ems_getThermostatTemps(); +void ems_getThermostatValues(); bool ems_getPoll(); +bool ems_getTxEnabled(); bool ems_getThermostatEnabled(); +bool ems_getBoilerEnabled(); _EMS_SYS_LOGGING ems_getLogging(); uint8_t ems_getEmsTypesCount(); +uint8_t ems_getThermostatTypesCount(); +bool ems_getEmsRefreshed(); void ems_printAllTypes(); +void ems_printThermostatType(); +void ems_printTxQueue(); // private functions uint8_t _crcCalculator(uint8_t * data, uint8_t len); void _processType(uint8_t * telegram, uint8_t length); -void _initTxBuffer(); -void _buildTxTelegram(uint8_t data_value); void _debugPrintPackage(const char * prefix, uint8_t * data, uint8_t len, const char * color); +void _ems_clearTxData(); // global so can referenced in other classes extern _EMS_Sys_Status EMS_Sys_Status; -extern _EMS_TxTelegram EMS_TxTelegram; extern _EMS_Boiler EMS_Boiler; extern _EMS_Thermostat EMS_Thermostat; diff --git a/src/my_config.h b/src/my_config.h index 86d5f0f57..d2a1a4840 100644 --- a/src/my_config.h +++ b/src/my_config.h @@ -25,20 +25,21 @@ #define BOILER_SHOWER_TIMER 1 // monitors how long a shower has taken #define BOILER_SHOWER_ALERT 0 // send alert if showetime exceeded -// define here the Thermostat type. see ems.h for options -//#define EMS_ID_THERMOSTAT EMS_ID_THERMOSTAT_RC20 // your thermostat ID -#define EMS_ID_THERMOSTAT EMS_ID_THERMOSTAT_EASY +// define here the Thermostat type. see ems.h for the supported types +#define EMS_ID_THERMOSTAT EMS_ID_THERMOSTAT_RC20 +//#define EMS_ID_THERMOSTAT EMS_ID_THERMOSTAT_RC30 +//#define EMS_ID_THERMOSTAT EMS_ID_THERMOSTAT_EASY // trigger settings to determine if hot tap water or the heating is active #define EMS_BOILER_BURNPOWER_TAPWATER 100 #define EMS_BOILER_SELFLOWTEMP_HEATING 70 -// if using the shower timer, change these settings (in ms) -#define SHOWER_PAUSE_TIME 15000 // 15 seconds, max time if water is switched off & on during a shower -#define SHOWER_MIN_DURATION 180000 // 3 minutes, before recognizing its a shower -#define SHOWER_MAX_DURATION 420000 // 7 minutes, before trigger a shot of cold water -#define SHOWER_COLDSHOT_DURATION 5 // 5 seconds for cold water - note, must be in seconds -#define SHOWER_OFFSET_TIME 8000 // 8 seconds grace time, to calibrate actual time under the shower +// if using the shower timer, change these settings +#define SHOWER_PAUSE_TIME 15000 // in ms. 15 seconds, max time if water is switched off & on during a shower +#define SHOWER_MIN_DURATION 120000 // in ms. 2 minutes, before recognizing its a shower +#define SHOWER_MAX_DURATION 420000 // in ms. 7 minutes, before trigger a shot of cold water +#define SHOWER_OFFSET_TIME 5000 // in ms. 5 seconds grace time, to calibrate actual time under the shower +#define SHOWER_COLDSHOT_DURATION 10 // in seconds. 10 seconds for cold water before turning back hot water // if using LEDs to show traffic, configure the GPIOs here // only works if -DUSE_LED is set in platformio.ini diff --git a/src/version.h b/src/version.h new file mode 100644 index 000000000..c65eb0bbe --- /dev/null +++ b/src/version.h @@ -0,0 +1,2 @@ +#define APP_NAME "EMS-ESP-Boiler" +#define APP_VERSION "1.1.0"