├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── include ├── LogStream.hpp ├── README ├── RinnaiProtocolDecoder.hpp ├── RinnaiMQTTGateway.hpp ├── config.hpp └── RinnaiSignalDecoder.hpp ├── test └── README ├── src ├── LogStream.cpp ├── RinnaiProtocolDecoder.cpp ├── main.cpp ├── RinnaiSignalDecoder.cpp └── RinnaiMQTTGateway.cpp ├── private_config.template.ini ├── LICENSE.md ├── lib └── README ├── platformio.ini ├── .travis.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .pio 2 | .vscode/.browse.c_cpp.db* 3 | .vscode/c_cpp_properties.json 4 | .vscode/launch.json 5 | .vscode/ipch 6 | private_config.ini 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "platformio.platformio-ide" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "array": "cpp", 4 | "deque": "cpp", 5 | "string": "cpp", 6 | "unordered_map": "cpp", 7 | "unordered_set": "cpp", 8 | "vector": "cpp", 9 | "initializer_list": "cpp", 10 | "*.tcc": "cpp", 11 | "algorithm": "cpp", 12 | "functional": "cpp", 13 | "random": "cpp", 14 | "type_traits": "cpp" 15 | } 16 | } -------------------------------------------------------------------------------- /include/LogStream.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | class LogStream 5 | { 6 | public: 7 | LogStream(Print &destination); 8 | Print & operator()(); 9 | void SetLogStreamTelnet(); 10 | void SetLogStreamSerial(); 11 | 12 | private: 13 | Print *destination; 14 | 15 | void SetLogStream(Print & _destination); 16 | }; 17 | 18 | extern LogStream logStream; // external reference for the global, singleton 19 | -------------------------------------------------------------------------------- /test/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for PIO Unit Testing and project tests. 3 | 4 | Unit Testing is a software testing method by which individual units of 5 | source code, sets of one or more MCU program modules together with associated 6 | control data, usage procedures, and operating procedures, are tested to 7 | determine whether they are fit for use. Unit testing finds problems early 8 | in the development cycle. 9 | 10 | More information about PIO Unit Testing: 11 | - https://docs.platformio.org/page/plus/unit-testing.html 12 | -------------------------------------------------------------------------------- /src/LogStream.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "LogStream.hpp" 4 | 5 | extern RemoteDebug remoteDebug; // defined and configured in main 6 | 7 | LogStream::LogStream(Print &destination) 8 | : destination(&destination) 9 | { 10 | } 11 | 12 | Print & LogStream::operator()() 13 | { 14 | return *destination; 15 | } 16 | 17 | void LogStream::SetLogStream(Print &_destination) 18 | { 19 | destination = &_destination; 20 | } 21 | 22 | void LogStream::SetLogStreamTelnet() 23 | { 24 | SetLogStream(remoteDebug); 25 | } 26 | 27 | void LogStream::SetLogStreamSerial() 28 | { 29 | SetLogStream(Serial); 30 | } 31 | 32 | LogStream logStream(Serial); // the global, singleton 33 | -------------------------------------------------------------------------------- /private_config.template.ini: -------------------------------------------------------------------------------- 1 | ; private extention of platformio.ini 2 | 3 | [env] 4 | # set some configuration parameters for the program 5 | # see top of main.cpp for documentation of the various parameters 6 | ota_password = example1 7 | src_build_flags = 8 | '-D WIFI_INITIAL_AP_PASSWORD="example2"' 9 | '-D OTA_PASSWORD="${env.ota_password}"' 10 | -D OTA_ENABLE_PIN=0 11 | -D WIFI_CONFIG_PIN=4 12 | -D TEST_PIN=0 13 | # make sure you know what you are doing if using input-only pins (34+) or straping pins (5, 12, 15) here: 14 | -D RX_RINNAI_PIN=25 15 | -D TX_IN_RINNAI_PIN=26 16 | -D TX_OUT_RINNAI_PIN=13 17 | 18 | [env:ota] 19 | #upload_port = 192.168.1.10 20 | #monitor_port = COM7 21 | 22 | [env:usb] 23 | #upload_port = COM7 24 | #monitor_port = COM7 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Arik Yavilevich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for project specific (private) libraries. 3 | PlatformIO will compile them to static libraries and link into executable file. 4 | 5 | The source code of each library should be placed in a an own separate directory 6 | ("lib/your_library_name/[here are source files]"). 7 | 8 | For example, see a structure of the following two libraries `Foo` and `Bar`: 9 | 10 | |--lib 11 | | | 12 | | |--Bar 13 | | | |--docs 14 | | | |--examples 15 | | | |--src 16 | | | |- Bar.c 17 | | | |- Bar.h 18 | | | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html 19 | | | 20 | | |--Foo 21 | | | |- Foo.c 22 | | | |- Foo.h 23 | | | 24 | | |- README --> THIS FILE 25 | | 26 | |- platformio.ini 27 | |--src 28 | |- main.c 29 | 30 | and a contents of `src/main.c`: 31 | ``` 32 | #include 33 | #include 34 | 35 | int main (void) 36 | { 37 | ... 38 | } 39 | 40 | ``` 41 | 42 | PlatformIO Library Dependency Finder will find automatically dependent 43 | libraries scanning project source files. 44 | 45 | More information about PlatformIO Library Dependency Finder 46 | - https://docs.platformio.org/page/librarymanager/ldf.html 47 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | extra_configs = private_config.ini 13 | 14 | [env] 15 | platform = espressif32 16 | board = esp32doit-devkit-v1 17 | framework = arduino 18 | #monitor_speed = 115200 19 | monitor_speed = 460800 20 | lib_deps = 21 | https://github.com/prampec/IotWebConf 22 | https://github.com/256dpi/arduino-mqtt 23 | https://github.com/bblanchon/ArduinoJson 24 | https://github.com/JoaoLopesF/RemoteDebug 25 | monitor_filters = esp32_exception_decoder 26 | build_type = debug # for the above filter to work 27 | build_flags = -D SERIAL_BAUD=${env.monitor_speed} 28 | 29 | [env:ota] 30 | upload_protocol = espota 31 | upload_port = rinnai-wifi 32 | #upload_port = rinnai-wifi.local # this might need A Bonjour Service to work 33 | # or define upload_port with an IP in private_config.ini 34 | upload_flags = 35 | --port=3232 36 | --auth=${env.ota_password} 37 | 38 | [env:usb] 39 | upload_protocol = esptool 40 | -------------------------------------------------------------------------------- /include/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for project header files. 3 | 4 | A header file is a file containing C declarations and macro definitions 5 | to be shared between several project source files. You request the use of a 6 | header file in your project source file (C, C++, etc) located in `src` folder 7 | by including it, with the C preprocessing directive `#include'. 8 | 9 | ```src/main.c 10 | 11 | #include "header.h" 12 | 13 | int main (void) 14 | { 15 | ... 16 | } 17 | ``` 18 | 19 | Including a header file produces the same results as copying the header file 20 | into each source file that needs it. Such copying would be time-consuming 21 | and error-prone. With a header file, the related declarations appear 22 | in only one place. If they need to be changed, they can be changed in one 23 | place, and programs that include the header file will automatically use the 24 | new version when next recompiled. The header file eliminates the labor of 25 | finding and changing all the copies as well as the risk that a failure to 26 | find one copy will result in inconsistencies within a program. 27 | 28 | In C, the usual convention is to give header files names that end with `.h'. 29 | It is most portable to use only letters, digits, dashes, and underscores in 30 | header file names, and at most one dot. 31 | 32 | Read more about using header files in official GCC documentation: 33 | 34 | * Include Syntax 35 | * Include Operation 36 | * Once-Only Headers 37 | * Computed Includes 38 | 39 | https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html 40 | -------------------------------------------------------------------------------- /include/RinnaiProtocolDecoder.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | enum RinnaiPacketSource 5 | { 6 | INVALID, // packet is invalid 7 | UNKNOWN, // unable to identify device 8 | HEATER, // the main device 9 | CONTROL, // one of control panels 10 | }; 11 | 12 | struct RinnaiHeaterPacket 13 | { 14 | byte activeId; 15 | bool on; 16 | bool inUse; 17 | byte temperatureCelsius; 18 | byte startupState; 19 | }; 20 | 21 | struct RinnaiControlPacket 22 | { 23 | byte myId; 24 | bool onOffPressed; 25 | bool priorityPressed; 26 | bool temperatureUpPressed; 27 | bool temperatureDownPressed; 28 | }; 29 | 30 | // this class is able to decode and modify Rinnai packets 31 | // decoding is based on observations so it is not full 32 | // specifications for constructing a valid packet are not known 33 | class RinnaiProtocolDecoder 34 | { 35 | public: 36 | static const int BYTES_IN_PACKET = 6; 37 | // temperatures allowed to set with this code (linear range) 38 | static const byte TEMP_C_MIN = 37; 39 | static const byte TEMP_C_MAX = 48; 40 | 41 | static RinnaiPacketSource getPacketSource(const byte * data, int length); 42 | static bool decodeHeaterPacket(const byte * data, RinnaiHeaterPacket &packet); 43 | static bool decodeControlPacket(const byte * data, RinnaiControlPacket &packet); 44 | static String renderPacket(const byte * data); 45 | 46 | static void setOnOffPressed(byte * data); 47 | static void setPriorityPressed(byte * data); 48 | static void setTemperatureUpPressed(byte * data); 49 | static void setTemperatureDownPressed(byte * data); 50 | 51 | private: 52 | static bool temperatureCodeToTemperatureCelsius(byte code, byte & temperature); 53 | static void calcAndSetChecksum(byte * data); 54 | static bool isOddParity(byte b); 55 | }; 56 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Continuous Integration (CI) is the practice, in software 2 | # engineering, of merging all developer working copies with a shared mainline 3 | # several times a day < https://docs.platformio.org/page/ci/index.html > 4 | # 5 | # Documentation: 6 | # 7 | # * Travis CI Embedded Builds with PlatformIO 8 | # < https://docs.travis-ci.com/user/integration/platformio/ > 9 | # 10 | # * PlatformIO integration with Travis CI 11 | # < https://docs.platformio.org/page/ci/travis.html > 12 | # 13 | # * User Guide for `platformio ci` command 14 | # < https://docs.platformio.org/page/userguide/cmd_ci.html > 15 | # 16 | # 17 | # Please choose one of the following templates (proposed below) and uncomment 18 | # it (remove "# " before each line) or use own configuration according to the 19 | # Travis CI documentation (see above). 20 | # 21 | 22 | 23 | # 24 | # Template #1: General project. Test it using existing `platformio.ini`. 25 | # 26 | 27 | # language: python 28 | # python: 29 | # - "2.7" 30 | # 31 | # sudo: false 32 | # cache: 33 | # directories: 34 | # - "~/.platformio" 35 | # 36 | # install: 37 | # - pip install -U platformio 38 | # - platformio update 39 | # 40 | # script: 41 | # - platformio run 42 | 43 | 44 | # 45 | # Template #2: The project is intended to be used as a library with examples. 46 | # 47 | 48 | # language: python 49 | # python: 50 | # - "2.7" 51 | # 52 | # sudo: false 53 | # cache: 54 | # directories: 55 | # - "~/.platformio" 56 | # 57 | # env: 58 | # - PLATFORMIO_CI_SRC=path/to/test/file.c 59 | # - PLATFORMIO_CI_SRC=examples/file.ino 60 | # - PLATFORMIO_CI_SRC=path/to/test/directory 61 | # 62 | # install: 63 | # - pip install -U platformio 64 | # - platformio update 65 | # 66 | # script: 67 | # - platformio ci --lib="." --board=ID_1 --board=ID_2 --board=ID_N 68 | -------------------------------------------------------------------------------- /include/RinnaiMQTTGateway.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | #include 5 | 6 | #include "RinnaiSignalDecoder.hpp" 7 | #include "RinnaiProtocolDecoder.hpp" 8 | 9 | enum DebugLevel 10 | { 11 | NONE, 12 | PARSED, 13 | RAW, 14 | }; 15 | 16 | enum OverrideCommand 17 | { 18 | ON_OFF, 19 | PRIORITY, 20 | TEMPERATURE_UP, 21 | TEMPERATURE_DOWN, 22 | }; 23 | 24 | // this class will handle the logic of converting between MQTT commands and Rinnai packets 25 | class RinnaiMQTTGateway 26 | { 27 | public: 28 | RinnaiMQTTGateway(String haDeviceName, RinnaiSignalDecoder & rxDecoder, RinnaiSignalDecoder & txDecoder, MQTTClient & mqttClient, String mqttTopic, byte testPin); 29 | 30 | void loop(); 31 | void onMqttMessageReceived(String &topic, String &payload); 32 | void onMqttConnected(); 33 | 34 | private: 35 | // private functions 36 | bool handleIncomingPacketQueueItem(const PacketQueueItem & item, bool remote); 37 | void handleTemperatureSync(); 38 | bool override(OverrideCommand command); 39 | long millisDelta(unsigned long t1, unsigned long t2); 40 | long millisDeltaPositive(unsigned long t1, unsigned long t2, unsigned long cycle); 41 | 42 | // properties 43 | String haDeviceName; 44 | RinnaiSignalDecoder & rxDecoder; 45 | RinnaiSignalDecoder & txDecoder; 46 | MQTTClient & mqttClient; 47 | String mqttTopic; 48 | String mqttTopicState; 49 | byte testPin; 50 | DebugLevel logLevel = NONE; 51 | bool enableTemperatureSync = true; // on by default on startup, if needed this default can be made into a build option 52 | int targetTemperatureCelsius = -1; 53 | 54 | unsigned long lastMqttReportMillis = 0; 55 | String lastMqttReportPayload; 56 | 57 | byte lastHeaterPacketBytes[RinnaiProtocolDecoder::BYTES_IN_PACKET]; 58 | byte lastLocalControlPacketBytes[RinnaiProtocolDecoder::BYTES_IN_PACKET]; 59 | byte lastRemoteControlPacketBytes[RinnaiProtocolDecoder::BYTES_IN_PACKET]; 60 | byte lastUnknownPacketBytes[RinnaiProtocolDecoder::BYTES_IN_PACKET]; 61 | RinnaiHeaterPacket lastHeaterPacketParsed; 62 | RinnaiControlPacket lastLocalControlPacketParsed; 63 | RinnaiControlPacket lastRemoteControlPacketParsed; 64 | int heaterPacketCounter = 0; 65 | int localControlPacketCounter = 0; 66 | int remoteControlPacketCounter = 0; 67 | int unknownPacketCounter = 0; 68 | unsigned long lastHeaterPacketMillis = 0; 69 | unsigned long lastHeaterPacketDeltaMillis = 0; 70 | unsigned long lastLocalControlPacketMillis = 0; 71 | unsigned long lastRemoteControlPacketMillis = 0; 72 | unsigned long lastUnknownPacketMillis = 0; 73 | }; 74 | -------------------------------------------------------------------------------- /include/config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // check (required) parameters passed from the ini 4 | // create your own private_config.ini with the data. See private_config.template.ini 5 | #ifndef SERIAL_BAUD 6 | #error Need to pass SERIAL_BAUD 7 | #endif 8 | // Name of the device in homa-assistant 9 | #ifndef HA_DEVICE_NAME 10 | #define HA_DEVICE_NAME "Rinnai Water Heater" 11 | #endif 12 | // Initial name of the Thing. Used e.g. as SSID of the own Access Point. 13 | #ifndef HOST_NAME // there could be esp-idf bugs setting the DHCP hostname, it will be empty or "espressif", wait for fixes. 14 | #define HOST_NAME "rinnai-wifi" 15 | #endif 16 | // Initial password to connect to the Thing, when it creates an own Access Point. 17 | #ifndef WIFI_INITIAL_AP_PASSWORD 18 | #define WIFI_INITIAL_AP_PASSWORD "rinnairinnai" // must be over 8 characters 19 | #endif 20 | // OTA password 21 | #ifndef OTA_PASSWORD 22 | #error Need to define ota_password / OTA_PASSWORD 23 | #endif 24 | // Thing will stay in AP mode for an amount of time on boot, before retrying to connect to a WiFi network. 25 | #ifndef AP_MODE_TIMEOUT_MS 26 | #define AP_MODE_TIMEOUT_MS 5000 27 | #endif 28 | // Restrict OTA updates based on state of a pin 29 | #ifndef OTA_ENABLE_PIN 30 | #define OTA_ENABLE_PIN -1 // if set to !=-1, drive this pin low to allow OTA updates 31 | #endif 32 | // MQTT topic prefix 33 | #ifndef MQTT_TOPIC 34 | #define MQTT_TOPIC "homeassistant/climate/rinnai" 35 | #endif 36 | // When WIFI_CONFIG_PIN is pulled to ground on startup, the Thing will use the initial password to build an AP. (E.g. in case of lost password) 37 | #ifndef WIFI_CONFIG_PIN 38 | #error Need to define WIFI_CONFIG_PIN 39 | #endif 40 | // Pin whose state to send over mqtt 41 | #ifndef TEST_PIN 42 | #error Need to define TEST_PIN 43 | #endif 44 | // Rinnai rx and tx pins 45 | #ifndef RX_RINNAI_PIN // pin carrying data from the outside (heater, other control panels, etc) 46 | #error Need to define RX_RINNAI_PIN 47 | #endif 48 | #ifndef RX_INVERT // "true" if we need to invert the incoming signal, set when an inverting mosfet is used to level shift the signal from 5v to 3.3V 49 | #define RX_INVERT false 50 | #endif 51 | #ifndef TX_IN_RINNAI_PIN // pin carrying data from the local control panel mcu 52 | #error Need to define TX_IN_RINNAI_PIN 53 | #endif 54 | #ifndef TX_IN_INVERT // "true" if we need to invert the incoming signal, set when an inverting mosfet is used to level shift the signal from 5v to 3.3V 55 | #define TX_IN_INVERT false 56 | #endif 57 | #ifndef TX_OUT_INVERT // "true" if we need to invert the outgoing signal, set when an inverting mosfet is used to level shift the signal from 3.3V to 5V 58 | #define TX_OUT_INVERT false 59 | #endif 60 | #ifndef TX_OUT_RINNAI_PIN // the exit of the proxy, data from the local mcu with optional changes 61 | #define TX_OUT_RINNAI_PIN -1 // default is a read only mode without overriding commands 62 | #endif 63 | -------------------------------------------------------------------------------- /include/RinnaiSignalDecoder.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | const byte INVALID_PIN = -1; 5 | 6 | // this class decodes pulse length encoded Rinnai data coming from a pin and converts it to bytes 7 | // this class is also capable of overwriting a packet with override data (proxy functionality) 8 | class RinnaiSignalDecoder 9 | { 10 | public: 11 | RinnaiSignalDecoder(const byte pin, const byte proxyOutPin = INVALID_PIN, const bool invertIn = false, const bool invertOut = false); 12 | bool setup(); 13 | 14 | // expose properties 15 | QueueHandle_t getPulseQueue() 16 | { 17 | return pulseQueue; 18 | } 19 | QueueHandle_t getBitQueue() 20 | { 21 | return bitQueue; 22 | } 23 | QueueHandle_t getPacketQueue() 24 | { 25 | return packetQueue; 26 | } 27 | 28 | unsigned int getPulseHandlerErrorCounter() 29 | { 30 | return pulseHandlerErrorCounter; 31 | } 32 | unsigned int getBitTaskErrorCounter() 33 | { 34 | return bitTaskErrorCounter; 35 | } 36 | unsigned int getPacketTaskErrorCounter() 37 | { 38 | return packetTaskErrorCounter; 39 | } 40 | 41 | bool setOverridePacket(const byte * data, int length); 42 | 43 | static const int BYTES_IN_PACKET = 6; 44 | 45 | private: 46 | // private functions 47 | void pulseISRHandler(); 48 | static void pulseISRHandler(void *); 49 | void bitTaskHandler(); 50 | void packetTaskHandler(); 51 | void overrideTaskHandler(); 52 | void writeOverridePacket(); 53 | static void writePacket(const byte pin, const byte * data, const byte len, const bool invert = false); 54 | static bool isOddParity(byte b); 55 | 56 | // properties 57 | byte pin = INVALID_PIN; 58 | byte proxyOutPin = INVALID_PIN; 59 | bool invertIn = false; 60 | bool invertOut = false; 61 | QueueHandle_t pulseQueue = NULL; 62 | QueueHandle_t bitQueue = NULL; 63 | QueueHandle_t packetQueue = NULL; 64 | TaskHandle_t bitTask = NULL; 65 | TaskHandle_t packetTask = NULL; 66 | TaskHandle_t overrideTask = NULL; 67 | // packet override props 68 | byte overridePacket[BYTES_IN_PACKET]; 69 | bool overridePacketSet = false; 70 | unsigned int lastPulseCycle = 0; 71 | bool isOverriding = false; 72 | 73 | unsigned int pulseHandlerErrorCounter = 0; 74 | unsigned int bitTaskErrorCounter = 0; 75 | unsigned int packetTaskErrorCounter = 0; 76 | }; 77 | 78 | struct PulseQueueItem 79 | { 80 | byte newLevel; // raise = 1, fall = 0 81 | unsigned int cycle; // when did it happen 82 | }; 83 | 84 | enum BIT // enum for values of the bit queue item 85 | { 86 | SYM0 = 0, // "0" 87 | SYM1, // "1" 88 | PRE, 89 | ERROR, 90 | }; 91 | 92 | struct BitQueueItem 93 | { 94 | BIT bit; // what bit is it? 95 | unsigned int startCycle; // when did it start 96 | int misc; // for testing 97 | }; 98 | 99 | struct PacketQueueItem 100 | { 101 | byte data[RinnaiSignalDecoder::BYTES_IN_PACKET]; 102 | unsigned int startCycle; // when did it start (using core cycle counter) 103 | unsigned long startMicros; // when did it start (using a counter that overflows less and has a defined origin) 104 | unsigned long startMillis; // when did it start (using a counter that overflows less and has a defined origin) 105 | byte bitsPresent; 106 | bool validPre; 107 | bool validChecksum; 108 | bool validParity; 109 | }; 110 | -------------------------------------------------------------------------------- /src/RinnaiProtocolDecoder.cpp: -------------------------------------------------------------------------------- 1 | #include "RinnaiProtocolDecoder.hpp" 2 | 3 | const byte TEMP_MAX_CODE = 0xe; // the max valid code 4 | const byte TEMP_CODE[] = {37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 55, 60}; 5 | 6 | RinnaiPacketSource RinnaiProtocolDecoder::getPacketSource(const byte *data, int length) 7 | { 8 | // check packet size 9 | if (length != BYTES_IN_PACKET) 10 | { 11 | return INVALID; 12 | } 13 | // check that packet is checksum valid 14 | byte checksum = 0; 15 | for (int i = 0; i < BYTES_IN_PACKET; i++) 16 | { 17 | if (!isOddParity(data[i])) 18 | { 19 | return INVALID; 20 | } 21 | checksum ^= data[i]; 22 | } 23 | if (checksum != 0) 24 | { 25 | return INVALID; 26 | } 27 | // see who the sender is 28 | if ((data[0] & 0xf) == 0x7 && data[4] == 0x20) 29 | { 30 | return HEATER; 31 | } 32 | if ((data[0] & 0xf) < 0x7 && data[4] == 0xbf) 33 | { 34 | return CONTROL; 35 | } 36 | return UNKNOWN; 37 | } 38 | 39 | bool RinnaiProtocolDecoder::isOddParity(byte b) 40 | { 41 | // https://stackoverflow.com/questions/21617970/how-to-check-if-value-has-even-parity-of-bits-or-odd 42 | b ^= b >> 4; 43 | b ^= b >> 2; 44 | b ^= b >> 1; 45 | return b & 1; 46 | } 47 | 48 | // assume packet passed getPacketSource==HEATER 49 | bool RinnaiProtocolDecoder::decodeHeaterPacket(const byte *data, RinnaiHeaterPacket &packet) 50 | { 51 | packet.activeId = (data[0] >> 4) & 0x7; 52 | packet.inUse = data[2] & 0x10; 53 | packet.on = data[1] & 0x40; 54 | packet.startupState = data[3] & 0x7f; 55 | bool ret = temperatureCodeToTemperatureCelsius(data[2] & 0xf, packet.temperatureCelsius); 56 | return ret; 57 | } 58 | 59 | // assume packet passed getPacketSource==CONTROL 60 | bool RinnaiProtocolDecoder::decodeControlPacket(const byte *data, RinnaiControlPacket &packet) 61 | { 62 | packet.myId = data[0] & 0xf; 63 | packet.onOffPressed = data[1] & 0x1; 64 | packet.priorityPressed = data[1] & 0x4; 65 | packet.temperatureUpPressed = data[2] & 0x1; 66 | packet.temperatureDownPressed = data[2] & 0x2; 67 | return true; 68 | } 69 | 70 | bool RinnaiProtocolDecoder::temperatureCodeToTemperatureCelsius(byte code, byte &temperature) 71 | { 72 | if (code > TEMP_MAX_CODE) 73 | { 74 | temperature = 0; 75 | return false; 76 | } 77 | temperature = TEMP_CODE[code]; 78 | return true; 79 | } 80 | 81 | // assume packet passed getPacketSource 82 | String RinnaiProtocolDecoder::renderPacket(const byte *data) 83 | { 84 | char result[BYTES_IN_PACKET * 3]; 85 | snprintf(result, BYTES_IN_PACKET * 3, "%02x,%02x,%02x,%02x,%02x", data[0] & 0x7f, data[1] & 0x7f, data[2] & 0x7f, data[3] & 0x7f, data[4] & 0x7f); 86 | return result; 87 | } 88 | 89 | void RinnaiProtocolDecoder::calcAndSetChecksum(byte *data) 90 | { 91 | byte checksum = 0; 92 | for (int i = 0; i < BYTES_IN_PACKET - 1; i++) 93 | { 94 | // recalc parity for byte 95 | data[i] &= 0x7f; // remove parity bit 96 | data[i] |= isOddParity(data[i]) ? 0x00 : 0x80; // turn on parity bit if needed 97 | // update checksum 98 | checksum ^= data[i]; 99 | } 100 | data[BYTES_IN_PACKET - 1] = checksum; 101 | } 102 | 103 | void RinnaiProtocolDecoder::setOnOffPressed(byte *data) 104 | { 105 | data[1] |= 0x01; // set button bit 106 | calcAndSetChecksum(data); 107 | } 108 | 109 | void RinnaiProtocolDecoder::setPriorityPressed(byte *data) 110 | { 111 | data[1] |= 0x04; // set button bit 112 | calcAndSetChecksum(data); 113 | } 114 | 115 | void RinnaiProtocolDecoder::setTemperatureUpPressed(byte *data) 116 | { 117 | data[2] |= 0x01; // set button bit 118 | calcAndSetChecksum(data); 119 | } 120 | 121 | void RinnaiProtocolDecoder::setTemperatureDownPressed(byte *data) 122 | { 123 | data[2] |= 0x02; // set button bit 124 | calcAndSetChecksum(data); 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rinnai Wifi 2 | Firmware for an ESP32 module that interfaces a Rinnai Control Panel to Home Assistant via MQTT 3 | 4 | An article about the project: https://blog.yavilevich.com/2020/08/changing-a-dumb-rinnai-water-heater-to-a-smart-one/ 5 | 6 | Related projects: 7 | https://github.com/ayavilevich/rinnai-mock/ 8 | https://github.com/ayavilevich/rinnai-control-panel-sigrok-pd 9 | by others: 10 | https://github.com/genmeim/rinnai-serial-decoder 11 | 12 | ## MQTT protocol and topic syntax 13 | 14 | The MQTT topic prefix can be set in your ``private_config.ini`` . The setting is called ``MQTT_TOPIC`` and it defaults to ``homeassistant/climate/rinnai``. Below it will be referred to as just ``~``. 15 | 16 | ### ~/config 17 | Sent by the device to perform configuration. The message contains a json payload holding the description of the device for the purpose of https://www.home-assistant.io/docs/mqtt/discovery/ as an https://www.home-assistant.io/integrations/climate.mqtt/ device. 18 | 19 | 20 | Example: 21 | 22 | { 23 | "~": "homeassistant/climate/rinnai", 24 | "name": "Rinnai Water Heater", 25 | "action_topic": "~/state", 26 | "action_template": "{{ value_json.action }}", 27 | "current_temperature_topic": "~/state", 28 | "current_temperature_template": "{{ value_json.currentTemperature }}", 29 | "max_temp": 48, 30 | "min_temp": 37, 31 | "initial": 37, 32 | "mode_command_topic": "~/mode", 33 | "mode_state_topic": "~/state", 34 | "mode_state_template": "{{ value_json.mode }}", 35 | "modes": [ 36 | "off", 37 | "heat" 38 | ], 39 | "precision": 1, 40 | "temperature_command_topic": "~/temp", 41 | "temperature_unit": "C", 42 | "temperature_state_topic": "~/state", 43 | "temperature_state_template": "{{ value_json.targetTemperature }}", 44 | "availability_topic": "~/availability" 45 | } 46 | 47 | ### ~/state 48 | Sent by the device to update its state. The message contains a json topic holding most of the state of the device. 49 | 50 | Example (with REPORT\_RESEARCH\_FIELDS == true): 51 | 52 | { 53 | "ip": "192.168.1.10", 54 | "testPin": "OFF", 55 | "enableTemperatureSync": true, 56 | "currentTemperature": 40, 57 | "targetTemperature": 40, 58 | "mode": "off", 59 | "action": "off", 60 | "activeId": 0, 61 | "heaterBytes": "07,01,03,50,20", 62 | "startupState": 80, 63 | "locControlId": 0, 64 | "locControlBytes": "00,00,00,5f,3f", 65 | "rssi": -83, 66 | "heaterDelta": 199, 67 | "locControlTiming": 81, 68 | "remControlId": 6, 69 | "remControlBytes": "06,00,00,5f,3f", 70 | "remControlTiming": 40 71 | } 72 | 73 | ### ~/availability 74 | Sent by the device to update its availability. The payload is either "online" or "offline" per HA convention. The offline state is set using MQTT "last will" mechanism. 75 | 76 | ### ~/temp 77 | Received by the device to set the desired temperature. The payload is a number in the range TEMP\_C\_MIN to TEMP\_C\_MAX and must also be a valid setting for your device. 78 | Invalid values will set the minimal temperature. 79 | 80 | ### ~/temperature_sync 81 | Received by the device to enable or disable the temperature syncing functionality. The payload can be "on", "enable", "true" or "1" to enable sync and any other value to disable it. The default is "on". 82 | You would normally want this "on" except if troubleshooting. 83 | 84 | ### ~/mode 85 | Received by the device to set the desired operational mode. The payload can be either "off" or "heat". 86 | 87 | ### ~/priority 88 | Received by the device to request priority for this control panel from the heater. This topic has no payload. 89 | 90 | ### ~/log_level 91 | Received by the device to set the verbosity of the log. The payload can be either "none", "parsed" or "raw". 92 | 93 | ### ~/log_destination 94 | Received by the device to set the log medium. The payload can be "telnet" for sending the log using RemoteDebug library or anything else to send the log to the "Serial" device. 95 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include // built-in 4 | #include // https://github.com/prampec/IotWebConf 5 | #include 6 | #include // https://github.com/256dpi/arduino-mqtt 7 | #include // https://github.com/JoaoLopesF/RemoteDebug 8 | 9 | #include "LogStream.hpp" 10 | #include "RinnaiSignalDecoder.hpp" 11 | #include "RinnaiMQTTGateway.hpp" 12 | 13 | // settings managed through a private_config.ini file 14 | #include "config.hpp" 15 | 16 | // hardcoded settings (consider to move to separate config or to the ini) 17 | // mqtt 18 | const int MQTT_PACKET_MAX_SIZE = 700; // the config message is rather large, keep enough space 19 | // wifi manager - // max configuration paramter length 20 | const int WIFI_CONFIG_PARAM_MAX_LEN = 128; 21 | // wifi manager - Configuration specific key. The value should be modified if config structure was changed. 22 | const char WIFI_CONFIG_VERSION[] = "mqt1"; 23 | // wifi manager - Status indicator pin. 24 | // First it will light up (kept LOW), on Wifi connection it will blink, 25 | // when connected to the Wifi it will turn off (kept HIGH). 26 | const int WIFI_STATUS_PIN = LED_BUILTIN; 27 | 28 | // main objects 29 | DNSServer dnsServer; 30 | WebServer server(80); 31 | IotWebConf iotWebConf(HOST_NAME, &dnsServer, &server, WIFI_INITIAL_AP_PASSWORD, WIFI_CONFIG_VERSION); 32 | WiFiClient net; 33 | MQTTClient mqttClient(MQTT_PACKET_MAX_SIZE); 34 | RinnaiSignalDecoder rxDecoder(RX_RINNAI_PIN, INVALID_PIN, RX_INVERT); 35 | RinnaiSignalDecoder txDecoder(TX_IN_RINNAI_PIN, TX_OUT_RINNAI_PIN, TX_IN_INVERT, TX_OUT_INVERT); 36 | RinnaiMQTTGateway rinnaiMqttGateway(HA_DEVICE_NAME, rxDecoder, txDecoder, mqttClient, MQTT_TOPIC, TEST_PIN); 37 | RemoteDebug remoteDebug; 38 | 39 | // state 40 | boolean needReset = false; 41 | boolean needOTAConnect = false; 42 | boolean needMqttConnect = false; 43 | unsigned long lastMqttConnectionAttempt = 0; 44 | 45 | char mqttServerValue[WIFI_CONFIG_PARAM_MAX_LEN]; 46 | char mqttUserNameValue[WIFI_CONFIG_PARAM_MAX_LEN]; 47 | char mqttUserPasswordValue[WIFI_CONFIG_PARAM_MAX_LEN]; 48 | 49 | iotwebconf::ParameterGroup mqttGroup = iotwebconf::ParameterGroup("mqttGroup", ""); 50 | iotwebconf::TextParameter mqttServerParam = iotwebconf::TextParameter("MQTT server", "mqttServer", mqttServerValue, WIFI_CONFIG_PARAM_MAX_LEN); 51 | iotwebconf::TextParameter mqttUserNameParam = iotwebconf::TextParameter("MQTT user", "mqttUser", mqttUserNameValue, WIFI_CONFIG_PARAM_MAX_LEN); 52 | iotwebconf::PasswordParameter mqttUserPasswordParam = iotwebconf::PasswordParameter("MQTT password", "mqttPass", mqttUserPasswordValue, WIFI_CONFIG_PARAM_MAX_LEN, "password"); 53 | 54 | // declarations 55 | void handleRoot(); 56 | void setupWifiManager(); 57 | void setupOTA(); 58 | void setupRemoteDebug(); 59 | void setupMqtt(); 60 | void connectWifi(const char *ssid, const char *password); 61 | void wifiConnected(); 62 | void configSaved(); 63 | boolean formValidator(iotwebconf::WebRequestWrapper* webRequestWrapper); 64 | boolean connectMqtt(); 65 | boolean connectMqttOptions(); 66 | void onMqttMessageReceived(String &topic, String &payload); 67 | 68 | // code 69 | void setup() 70 | { 71 | Serial.begin(SERIAL_BAUD); 72 | logStream().println(); 73 | logStream().println("Starting up..."); 74 | 75 | bool retRx = rxDecoder.setup(); 76 | logStream().printf("Finished setting up rx decoder, %d\n", retRx); 77 | bool retTx = txDecoder.setup(); 78 | logStream().printf("Finished setting up tx decoder, %d\n", retTx); 79 | if (!retRx || !retTx) 80 | { 81 | for (;;) 82 | ; // hang further execution 83 | } 84 | 85 | logStream().println("Setting up Wifi and Mqtt"); 86 | setupWifiManager(); 87 | setupMqtt(); 88 | 89 | logStream().println("Ready."); 90 | } 91 | 92 | void setupWifiManager() 93 | { 94 | // setup CONFIG pin ourselves otherwise pullup wasn't ready by the time iotWebConf tried to use it 95 | pinMode(WIFI_CONFIG_PIN, INPUT_PULLUP); 96 | delay(1); 97 | // -- Initializing the configuration. 98 | iotWebConf.setStatusPin(WIFI_STATUS_PIN); 99 | iotWebConf.setConfigPin(WIFI_CONFIG_PIN); 100 | mqttGroup.addItem(&mqttServerParam); 101 | mqttGroup.addItem(&mqttUserNameParam); 102 | mqttGroup.addItem(&mqttUserPasswordParam); 103 | iotWebConf.addParameterGroup(&mqttGroup); 104 | iotWebConf.setConfigSavedCallback(&configSaved); 105 | iotWebConf.setFormValidator(&formValidator); 106 | iotWebConf.setWifiConnectionCallback(&wifiConnected); 107 | iotWebConf.setWifiConnectionHandler(&connectWifi); 108 | iotWebConf.setApTimeoutMs(AP_MODE_TIMEOUT_MS); // try to shorten the time the device is in AP mode on boot 109 | 110 | // -- Initializing the configuration. 111 | boolean validConfig = iotWebConf.init(); 112 | if (!validConfig) 113 | { 114 | mqttServerValue[0] = '\0'; 115 | mqttUserNameValue[0] = '\0'; 116 | mqttUserPasswordValue[0] = '\0'; 117 | } 118 | 119 | // -- Set up required URL handlers on the web server. 120 | server.on("/", handleRoot); 121 | server.on("/config", [] { iotWebConf.handleConfig(); }); 122 | server.onNotFound([]() { iotWebConf.handleNotFound(); }); 123 | } 124 | 125 | // need to call once wifi is connected 126 | void setupOTA() 127 | { 128 | // allow pin 129 | if (OTA_ENABLE_PIN != -1) 130 | { 131 | pinMode(OTA_ENABLE_PIN, INPUT_PULLUP); 132 | } 133 | 134 | // Port defaults to 3232 135 | // ArduinoOTA.setPort(3232); 136 | 137 | // Hostname defaults to esp3232-[MAC] 138 | ArduinoOTA.setHostname(HOST_NAME); 139 | 140 | // No authentication by default 141 | ArduinoOTA.setPassword(OTA_PASSWORD); 142 | 143 | // Password can be set with it's md5 value as well 144 | // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3 145 | // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3"); 146 | 147 | ArduinoOTA.setMdnsEnabled(false); // we already have DNS from the wifi manager 148 | 149 | ArduinoOTA 150 | .onStart([]() { 151 | String type; 152 | if (ArduinoOTA.getCommand() == U_FLASH) 153 | type = "sketch"; 154 | else // U_SPIFFS 155 | type = "filesystem"; 156 | 157 | // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end() 158 | logStream().println("Start updating " + type); 159 | }) 160 | .onEnd([]() { 161 | logStream().println("\nEnd"); 162 | }) 163 | .onProgress([](unsigned int progress, unsigned int total) { 164 | logStream().printf("Progress: %u%%\r", (progress / (total / 100))); 165 | }) 166 | .onError([](ota_error_t error) { 167 | logStream().printf("Error[%u]: ", error); 168 | if (error == OTA_AUTH_ERROR) 169 | logStream().println("Auth Failed"); 170 | else if (error == OTA_BEGIN_ERROR) 171 | logStream().println("Begin Failed"); 172 | else if (error == OTA_CONNECT_ERROR) 173 | logStream().println("Connect Failed"); 174 | else if (error == OTA_RECEIVE_ERROR) 175 | logStream().println("Receive Failed"); 176 | else if (error == OTA_END_ERROR) 177 | logStream().println("End Failed"); 178 | }); 179 | 180 | ArduinoOTA.begin(); 181 | } 182 | 183 | // need to call once wifi is connected 184 | void setupRemoteDebug() 185 | { 186 | // Initialize RemoteDebug 187 | remoteDebug.begin(HOST_NAME); // Initialize the WiFi server. Can pass port but telnet port 23 is the default 188 | // remoteDebug.setResetCmdEnabled(true); // Enable the reset command 189 | // remoteDebug.showProfiler(true); // Profiler (Good to measure times, to optimize codes) 190 | remoteDebug.showColors(true); // Colors need ANSI supporting terminal 191 | } 192 | 193 | void setupMqtt() 194 | { 195 | mqttClient.begin(mqttServerValue, net); // use default port = 1883 196 | mqttClient.onMessage(onMqttMessageReceived); 197 | } 198 | 199 | void loop() 200 | { 201 | // -- doLoop should be called as frequently as possible. 202 | iotWebConf.doLoop(); 203 | // OTA loop, allow only if a certain button pin is enabled 204 | if (OTA_ENABLE_PIN == -1 || digitalRead(OTA_ENABLE_PIN) == LOW) 205 | { 206 | ArduinoOTA.handle(); // ok to call if not initialized yet, does nothing 207 | } 208 | // RemoteDebug handle 209 | remoteDebug.handle(); 210 | // MQTT loop 211 | mqttClient.loop(); 212 | 213 | // need to setup OTA after wifi connection 214 | if (needOTAConnect) 215 | { 216 | setupRemoteDebug(); 217 | setupOTA(); 218 | needOTAConnect = false; 219 | } 220 | 221 | // need to setup mqtt after wifi connection 222 | if (needMqttConnect) 223 | { 224 | if (connectMqtt()) 225 | { 226 | needMqttConnect = false; 227 | } 228 | } 229 | else if ((iotWebConf.getState() == iotwebconf::OnLine) && (!mqttClient.connected())) // keep mqtt connection 230 | { 231 | logStream().println("MQTT reconnect"); 232 | connectMqtt(); 233 | } 234 | 235 | // need to reset after config change 236 | if (needReset) 237 | { 238 | logStream().println("Rebooting after 1 second."); 239 | iotWebConf.delay(1000); 240 | ESP.restart(); 241 | } 242 | 243 | // mqtt and rinnai business logic 244 | rinnaiMqttGateway.loop(); 245 | // see if others want to do some work 246 | yield(); 247 | } 248 | 249 | /** 250 | * Handle web requests to "/" path. 251 | */ 252 | void handleRoot() 253 | { 254 | // -- Let IotWebConf test and handle captive portal requests. 255 | if (iotWebConf.handleCaptivePortal()) 256 | { 257 | // -- Captive portal request were already served. 258 | return; 259 | } 260 | String s = ""; 261 | s += "Rinnai Wifi"; 262 | s += "Go to configure page to change settings."; 263 | s += "\n"; 264 | 265 | server.send(200, "text/html", s); 266 | } 267 | 268 | // callback to connect to wifi 269 | void connectWifi(const char *ssid, const char *password) 270 | { 271 | // Attempt to fix issue setting the DHCP hostname. Unfortunately this just seems to set the hostname to "espressif" possibly due to: 272 | // https://github.com/espressif/esp-idf/issues/4737 273 | // If have working solution, report at: https://github.com/prampec/IotWebConf/issues/40 274 | /* 275 | WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE); // https://github.com/espressif/arduino-esp32/issues/2537 276 | WiFi.setHostname(HOST_NAME); 277 | */ 278 | WiFi.begin(ssid, password); 279 | } 280 | 281 | // callback when wifi is connected 282 | void wifiConnected() 283 | { 284 | needOTAConnect = true; 285 | needMqttConnect = true; 286 | } 287 | 288 | void configSaved() 289 | { 290 | logStream().println("Configuration was updated."); 291 | needReset = true; 292 | } 293 | 294 | boolean formValidator(iotwebconf::WebRequestWrapper* webRequestWrapper) 295 | { 296 | logStream().println("Validating form."); 297 | boolean valid = true; 298 | 299 | int l = server.arg(mqttServerParam.getId()).length(); 300 | if (l < 3) 301 | { 302 | mqttServerParam.errorMessage = "Please provide at least 3 characters!"; 303 | valid = false; 304 | } 305 | 306 | return valid; 307 | } 308 | 309 | boolean connectMqtt() 310 | { 311 | unsigned long now = millis(); 312 | if (1000 > now - lastMqttConnectionAttempt) 313 | { 314 | // Do not repeat within 1 sec. 315 | return false; 316 | } 317 | logStream().println("Connecting to MQTT server..."); 318 | if (!connectMqttOptions()) 319 | { 320 | lastMqttConnectionAttempt = now; 321 | return false; 322 | } 323 | logStream().println("Connected!"); 324 | 325 | rinnaiMqttGateway.onMqttConnected(); 326 | return true; 327 | } 328 | 329 | boolean connectMqttOptions() 330 | { 331 | boolean result; 332 | 333 | /* 334 | logStream().println("mqtt params:"); 335 | logStream().println(iotWebConf.getThingName()); 336 | logStream().println(mqttUserNameValue); 337 | logStream().println(mqttUserPasswordValue); 338 | */ 339 | 340 | if (mqttUserPasswordValue[0] != '\0') 341 | { 342 | result = mqttClient.connect(iotWebConf.getThingName(), mqttUserNameValue, mqttUserPasswordValue); 343 | } 344 | else if (mqttUserNameValue[0] != '\0') 345 | { 346 | result = mqttClient.connect(iotWebConf.getThingName(), mqttUserNameValue); 347 | } 348 | else 349 | { 350 | result = mqttClient.connect(iotWebConf.getThingName()); 351 | } 352 | return result; 353 | } 354 | 355 | void onMqttMessageReceived(String &topic, String &payload) 356 | { 357 | rinnaiMqttGateway.onMqttMessageReceived(topic, payload); 358 | 359 | // Note: Do not use the client in the callback to publish, subscribe or 360 | // unsubscribe as it may cause deadlocks when other things arrive while 361 | // sending and receiving acknowledgments. Instead, change a global variable, 362 | // or push to a queue and handle it in the loop after calling `client.loop()`. 363 | } 364 | -------------------------------------------------------------------------------- /src/RinnaiSignalDecoder.cpp: -------------------------------------------------------------------------------- 1 | #include "LogStream.hpp" 2 | #include "RinnaiSignalDecoder.hpp" 3 | 4 | const int PULSES_IN_BIT = 2; 5 | const int BITS_IN_PACKET = RinnaiSignalDecoder::BYTES_IN_PACKET * 8; 6 | const int MAX_PACKETS_IN_QUEUE = 3; 7 | 8 | const int TASK_STACK_DEPTH = 2000; // minimum is configMINIMAL_STACK_SIZE 9 | const int BIT_TASK_PRIORITY = 1; // Each task can have a priority between 0 and 24. The upper limit is defined by configMAX_PRIORITIES. The priority of the main loop is 1. 10 | const int PACKET_TASK_PRIORITY = 1; 11 | const int OVERRIDE_TASK_PRIORITY = 4; // high priority task, will block others while it is running 12 | 13 | const int SYMBOL_DURATION_US = 600; 14 | 15 | const int INIT_PULSE = 850; 16 | const int SHORT_PULSE = 150; 17 | const int LONG_PULSE = 450; 18 | 19 | const int SYMBOL_SHORT_PERIOD_RATIO_MIN = SYMBOL_DURATION_US * 15 / 100; 20 | const int SYMBOL_SHORT_PERIOD_RATIO_MAX = SYMBOL_DURATION_US * 35 / 100; 21 | const int SYMBOL_LONG_PERIOD_RATIO_MIN = SYMBOL_DURATION_US * 65 / 100; 22 | const int SYMBOL_LONG_PERIOD_RATIO_MAX = SYMBOL_DURATION_US * 85 / 100; 23 | 24 | // cycles of 200ms and 250ms were observed. A packet is 30ms long. Allow for 10ms of margin. 25 | const int PERIOD_BETWEEN_TX_PACKETS_MARGIN = 10000; // us 26 | const int EXPECTED_PERIOD_BETWEEN_TX_PACKETS_MIN = 200000 - 30000 - PERIOD_BETWEEN_TX_PACKETS_MARGIN; // us 27 | const int EXPECTED_PERIOD_BETWEEN_TX_PACKETS_MAX = 250000 - 30000 + PERIOD_BETWEEN_TX_PACKETS_MARGIN; // us 28 | 29 | enum BitTaskState 30 | { 31 | WAIT_PRE, 32 | WAIT_SYMBOL, 33 | }; 34 | 35 | RinnaiSignalDecoder::RinnaiSignalDecoder(const byte pin, const byte proxyOutPin, const bool invertIn, const bool invertOut) 36 | : pin(pin), proxyOutPin(proxyOutPin), invertIn(invertIn), invertOut(invertOut) 37 | { 38 | } 39 | 40 | // return true is setup is ok 41 | bool RinnaiSignalDecoder::setup() 42 | { 43 | // setup input pin 44 | // pinMode(pin, INPUT); // too basic 45 | gpio_pad_select_gpio(pin); 46 | gpio_set_direction((gpio_num_t)pin, GPIO_MODE_INPUT); // is this a valid cast to gpio_num_t? 47 | gpio_set_pull_mode((gpio_num_t)pin, GPIO_PULLUP_ONLY); 48 | gpio_set_intr_type((gpio_num_t)pin, GPIO_INTR_ANYEDGE); 49 | gpio_intr_enable((gpio_num_t)pin); 50 | // setup output pin 51 | if (proxyOutPin != INVALID_PIN) 52 | { 53 | pinMode(proxyOutPin, OUTPUT); 54 | digitalWrite(proxyOutPin, digitalRead(pin) ^ invertIn ^ invertOut); // outputting LOW will signal that we are ready to receive 55 | } 56 | 57 | // create interrupts 58 | // attachInterrupt(); // too basic 59 | // use either gpio_isr_register (global ISR for all pins) or gpio_install_isr_service + gpio_isr_handler_add (per pin) 60 | esp_err_t ret_isr; 61 | ret_isr = gpio_install_isr_service(ESP_INTR_FLAG_IRAM); // ESP_INTR_FLAG_IRAM -> code is in RAM -> allows the interrupt to run even during flash operations 62 | if (ret_isr != ESP_OK && ret_isr != ESP_ERR_INVALID_STATE) // ESP_ERR_INVALID_STATE -> already initialized 63 | { 64 | logStream().printf("Error installing isr, %d\n", ret_isr); 65 | return false; 66 | } 67 | ret_isr = gpio_isr_handler_add((gpio_num_t)pin, &RinnaiSignalDecoder::pulseISRHandler, this); 68 | if (ret_isr != ESP_OK) 69 | { 70 | logStream().printf("Error adding isr handler, %d\n", ret_isr); 71 | return false; 72 | } 73 | // create pulse queue 74 | pulseQueue = xQueueCreate(MAX_PACKETS_IN_QUEUE * BITS_IN_PACKET * PULSES_IN_BIT, sizeof(PulseQueueItem)); // every bit is two pulses (not including "pre" overhead) 75 | if (pulseQueue == 0) 76 | { 77 | logStream().printf("Error creating queue\n"); 78 | return false; 79 | } 80 | // create bit queue 81 | bitQueue = xQueueCreate(MAX_PACKETS_IN_QUEUE * BITS_IN_PACKET, sizeof(BitQueueItem)); 82 | if (bitQueue == 0) 83 | { 84 | logStream().printf("Error creating queue\n"); 85 | return false; 86 | } 87 | // create packet queue 88 | packetQueue = xQueueCreate(MAX_PACKETS_IN_QUEUE, sizeof(PacketQueueItem)); 89 | if (packetQueue == 0) 90 | { 91 | logStream().printf("Error creating queue\n"); 92 | return false; 93 | } 94 | // log 95 | logStream().printf("Created queues, now about to create tasks\n"); 96 | // create pulse to bit task 97 | BaseType_t ret; 98 | ret = xTaskCreate([](void *o) { static_cast(o)->bitTaskHandler(); }, 99 | "bit task", 100 | TASK_STACK_DEPTH, 101 | this, 102 | BIT_TASK_PRIORITY, 103 | &bitTask); 104 | if (ret != pdPASS) 105 | { 106 | logStream().printf("Error creating task, %d\n", ret); 107 | return false; 108 | } 109 | // create byte to packet task 110 | ret = xTaskCreate([](void *o) { static_cast(o)->packetTaskHandler(); }, 111 | "packet task", 112 | TASK_STACK_DEPTH, 113 | this, 114 | PACKET_TASK_PRIORITY, 115 | &packetTask); 116 | if (ret != pdPASS) 117 | { 118 | logStream().printf("Error creating task, %d\n", ret); 119 | return false; 120 | } 121 | // create packet override task 122 | ret = xTaskCreate([](void *o) { static_cast(o)->overrideTaskHandler(); }, 123 | "override task", 124 | TASK_STACK_DEPTH, 125 | this, 126 | OVERRIDE_TASK_PRIORITY, 127 | &overrideTask); 128 | if (ret != pdPASS) 129 | { 130 | logStream().printf("Error creating task, %d\n", ret); 131 | return false; 132 | } 133 | // return 134 | return true; 135 | } 136 | 137 | // forward calls to a member function 138 | void IRAM_ATTR RinnaiSignalDecoder::pulseISRHandler(void *arg) 139 | { 140 | static_cast(arg)->pulseISRHandler(); 141 | } 142 | 143 | // https://www.reddit.com/r/esp32/comments/f529hf/results_comparing_the_speeds_of_different_gpio/ 144 | int IRAM_ATTR gpio_get_level_IRAM(int gpio_num) 145 | { 146 | if (gpio_num < 32) 147 | { 148 | return (GPIO.in >> gpio_num) & 0x1; 149 | } 150 | else 151 | { 152 | return (GPIO.in1.data >> (gpio_num - 32)) & 0x1; 153 | } 154 | } 155 | 156 | // https://www.reddit.com/r/esp32/comments/f529hf/results_comparing_the_speeds_of_different_gpio/ 157 | void IRAM_ATTR gpio_set_level_IRAM(int gpio_num, int level) 158 | { 159 | if (gpio_num < 32) 160 | { 161 | if (level) 162 | { 163 | GPIO.out_w1ts = 1 << gpio_num; 164 | } 165 | else 166 | { 167 | GPIO.out_w1tc = 1 << gpio_num; 168 | } 169 | } 170 | else 171 | { 172 | if (level) 173 | { 174 | GPIO.out1_w1ts.data = 1 << (gpio_num - 32); 175 | } 176 | else 177 | { 178 | GPIO.out1_w1tc.data = 1 << (gpio_num - 32); 179 | } 180 | } 181 | } 182 | 183 | // handle pulse raise and falls 184 | void IRAM_ATTR RinnaiSignalDecoder::pulseISRHandler() 185 | { 186 | BaseType_t xHigherPriorityTaskWoken = pdFALSE; 187 | // read state 188 | PulseQueueItem item; 189 | item.cycle = xthal_get_ccount(); 190 | //item.newLevel = gpio_get_level((gpio_num_t)pin); // not IRAM safe 191 | item.newLevel = (bool)gpio_get_level_IRAM(pin) ^ invertIn; 192 | // track changes to output 193 | if (proxyOutPin != INVALID_PIN && !isOverriding) // if overriding proxy is enabled and we are not already overriding 194 | { 195 | // see if we need to start overriding 196 | if (overridePacketSet && item.newLevel) // if there is override data and it is a rise 197 | { 198 | unsigned int delta = clockCyclesToMicroseconds(item.cycle - lastPulseCycle); 199 | if (delta > EXPECTED_PERIOD_BETWEEN_TX_PACKETS_MIN && delta < EXPECTED_PERIOD_BETWEEN_TX_PACKETS_MAX) // and if timings match 200 | { 201 | isOverriding = true; 202 | // unblock high priority override task 203 | // use notifications https://www.freertos.org/RTOS-task-notifications.html, they are faster than semaphores 204 | vTaskNotifyGiveFromISR(overrideTask, &xHigherPriorityTaskWoken); 205 | } 206 | } 207 | if (!isOverriding) // only if we didn't start an override task above 208 | { 209 | gpio_set_level_IRAM(proxyOutPin, item.newLevel ^ invertOut); // mirror 210 | } 211 | } 212 | lastPulseCycle = item.cycle; 213 | // send pulse to queue 214 | BaseType_t ret = xQueueSendToBackFromISR(pulseQueue, &item, &xHigherPriorityTaskWoken); 215 | // ret: pdTRUE = 1; errQUEUE_FULL = 0; 216 | if (ret != pdTRUE) 217 | { 218 | // ets_printf("xQueueSendToBackFromISR %d\n", ret); 219 | pulseHandlerErrorCounter++; 220 | } 221 | // do context switch if it was requested 222 | if (xHigherPriorityTaskWoken) 223 | { 224 | portYIELD_FROM_ISR(); 225 | } 226 | } 227 | 228 | // start each iteration assuming pin is low 229 | // wait for switch to HIGH, then to LOW. measure length. 230 | // have timeouts in place 231 | void RinnaiSignalDecoder::bitTaskHandler() 232 | { 233 | logStream().println("bitTaskHandler started"); 234 | PulseQueueItem pulse; // we read these, process and push data to the bit queue 235 | unsigned int lastEndCycle = 0; 236 | for (;;) 237 | { 238 | // assume next edge is rise 239 | BaseType_t ret = xQueueReceive(pulseQueue, &pulse, portMAX_DELAY); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 240 | if (ret != pdTRUE || pulse.newLevel != 1) // if not rise or can't pull from queue 241 | { 242 | bitTaskErrorCounter++; 243 | } 244 | else 245 | { 246 | unsigned int risingCycle = pulse.cycle; 247 | // wait for fall 248 | ret = xQueueReceive(pulseQueue, &pulse, portMAX_DELAY); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 249 | if (ret != pdTRUE || pulse.newLevel != 0) 250 | { 251 | bitTaskErrorCounter++; 252 | } 253 | else 254 | { 255 | unsigned int fallingCycle = pulse.cycle; 256 | // we have 3 relevant timings: lastEndCycle, risingCycle and fallingCycle 257 | // convert 258 | unsigned long pulseLengthLow = clockCyclesToMicroseconds(risingCycle - lastEndCycle); 259 | unsigned long pulseLengthHigh = clockCyclesToMicroseconds(fallingCycle - risingCycle); 260 | // decide on what to register 261 | BitQueueItem value; // what to register? 262 | if (pulseLengthHigh > SYMBOL_DURATION_US && SYMBOL_DURATION_US < SYMBOL_DURATION_US * 2) // if valid pre pulse 263 | { 264 | // register a "pre" 265 | value.bit = PRE; 266 | value.startCycle = risingCycle; 267 | value.misc = pulseLengthHigh; 268 | } 269 | else if (pulseLengthLow > SYMBOL_SHORT_PERIOD_RATIO_MIN && pulseLengthLow < SYMBOL_SHORT_PERIOD_RATIO_MAX && pulseLengthHigh > SYMBOL_LONG_PERIOD_RATIO_MIN && pulseLengthHigh < SYMBOL_LONG_PERIOD_RATIO_MAX) 270 | { 271 | value.bit = SYM1; 272 | value.startCycle = lastEndCycle; 273 | value.misc = pulseLengthLow; 274 | } 275 | else if (pulseLengthLow > SYMBOL_LONG_PERIOD_RATIO_MIN && pulseLengthLow < SYMBOL_LONG_PERIOD_RATIO_MAX && pulseLengthHigh > SYMBOL_SHORT_PERIOD_RATIO_MIN && pulseLengthHigh < SYMBOL_SHORT_PERIOD_RATIO_MAX) 276 | { 277 | value.bit = SYM0; 278 | value.startCycle = lastEndCycle; 279 | value.misc = pulseLengthLow; 280 | } 281 | else 282 | { 283 | value.bit = ERROR; 284 | value.startCycle = lastEndCycle; 285 | value.misc = pulseLengthLow; 286 | } 287 | // register 288 | BaseType_t ret = xQueueSendToBack(bitQueue, &value, 0); // no wait 289 | if (ret != pdTRUE) 290 | { 291 | // inc error counter 292 | bitTaskErrorCounter++; 293 | } 294 | } 295 | // save last for next iteration 296 | lastEndCycle = pulse.cycle; 297 | } 298 | } 299 | } 300 | 301 | void RinnaiSignalDecoder::packetTaskHandler() 302 | { 303 | logStream().println("packetTaskHandler started"); 304 | BitQueueItem bit; // we read these, process and push data to the packet queue 305 | 306 | PacketQueueItem packet; // current state 307 | packet.bitsPresent = 0; 308 | packet.startCycle = 0; 309 | packet.startMicros = 0; 310 | packet.startMillis = 0; 311 | packet.validPre = false; 312 | packet.validChecksum = false; 313 | packet.validParity = false; 314 | memset(packet.data, 0, sizeof(packet.data)); 315 | 316 | for (;;) 317 | { 318 | BaseType_t ret = xQueueReceive(bitQueue, &bit, portMAX_DELAY); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 319 | if (ret != pdTRUE) // if can't pull from queue 320 | { 321 | packetTaskErrorCounter++; 322 | } 323 | else 324 | { 325 | switch (bit.bit) 326 | { 327 | case SYM0: 328 | // nothing to store, just move forward 329 | packet.bitsPresent++; 330 | break; 331 | case SYM1: 332 | // store bit in packet data 333 | if (packet.bitsPresent < BITS_IN_PACKET) 334 | { 335 | packet.data[packet.bitsPresent / 8] |= (1 << (packet.bitsPresent % 8)); 336 | } 337 | packet.bitsPresent++; 338 | break; 339 | case ERROR: 340 | default: 341 | packetTaskErrorCounter++; 342 | case PRE: 343 | // flush current (invalid packet) ? 344 | // reset state 345 | packet.bitsPresent = 0; 346 | packet.startCycle = bit.startCycle; 347 | packet.startMicros = micros(); // this is the time of processing the bit queue item and not exact time of the pulse in the ISR. it was accurate to a ms level most of the time. 348 | // it is not possible to compensate for the difference using clockCyclesToMicroseconds(xthal_get_ccount() - bit.startCycle) because "xthal_get_ccount" is core specific and this task is not pinned to a specific core. 349 | // the difference is about 1ms, though, assuming one of the cores can run this task and we are not "stuck" on high priority tasks. This can be observed using xPortGetCoreID() and the expression above. 350 | packet.startMillis = millis(); // millis and micros come from the same 64bit counter (esp_timer_get_time()) but they overflow/wrap differently. 351 | packet.validPre = bit.bit == PRE; 352 | packet.validChecksum = false; 353 | packet.validParity = false; 354 | memset(packet.data, 0, sizeof(packet.data)); 355 | } 356 | // see if we completed a packet 357 | if (packet.bitsPresent == BITS_IN_PACKET) 358 | { 359 | // check parity (each data byte has “odd parity bit” as the MSB bit) 360 | packet.validParity = true; // be optimistic 361 | for (int i = 0; i < BYTES_IN_PACKET - 1; i++) 362 | { 363 | if (!isOddParity(packet.data[i])) 364 | { 365 | packet.validParity = false; 366 | } 367 | } 368 | // check checksum (last byte is xor of first 5 bytes) 369 | byte checksum = 0; 370 | for (int i = 0; i < BYTES_IN_PACKET; i++) 371 | { 372 | checksum ^= packet.data[i]; 373 | } 374 | packet.validChecksum = checksum == 0; 375 | // send 376 | BaseType_t ret = xQueueSendToBack(packetQueue, &packet, 0); // no wait 377 | if (ret != pdTRUE) 378 | { 379 | // inc error counter 380 | packetTaskErrorCounter++; 381 | } 382 | // reset state, so if we keep getting bytes then we result in error packets 383 | packet.bitsPresent = 0; 384 | packet.validPre = false; 385 | packet.validChecksum = false; 386 | packet.validParity = false; 387 | memset(packet.data, 0, sizeof(packet.data)); 388 | } 389 | } 390 | } 391 | } 392 | 393 | bool RinnaiSignalDecoder::isOddParity(byte b) 394 | { 395 | // https://stackoverflow.com/questions/21617970/how-to-check-if-value-has-even-parity-of-bits-or-odd 396 | b ^= b >> 4; 397 | b ^= b >> 2; 398 | b ^= b >> 1; 399 | return b & 1; 400 | } 401 | 402 | // wait for signals to override then flush previously set bytes 403 | void RinnaiSignalDecoder::overrideTaskHandler() 404 | { 405 | logStream().println("overrideTaskHandler started"); 406 | for (;;) 407 | { 408 | /* Wait to be notified that we need to do work. Note the first 409 | parameter is pdTRUE, which has the effect of clearing the task's notification 410 | value back to 0, making the notification value act like a binary (rather than 411 | a counting) semaphore. */ 412 | uint32_t ulNotificationValue = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); 413 | 414 | if (ulNotificationValue == 1) 415 | { 416 | // we got a notification, write data 417 | writeOverridePacket(); 418 | delayMicroseconds(PERIOD_BETWEEN_TX_PACKETS_MARGIN * 2); // delay to make sure we cover the original changes 419 | // we finished, clear state 420 | overridePacketSet = false; // this makes sure a packet is only sent once 421 | isOverriding = false; 422 | } 423 | else 424 | { 425 | // timeout 426 | } 427 | } 428 | } 429 | 430 | void RinnaiSignalDecoder::writeOverridePacket() 431 | { 432 | writePacket(proxyOutPin, overridePacket, BYTES_IN_PACKET, invertOut); 433 | } 434 | 435 | // this is a bit-bang blocking function, consider other options to send pulses without blocking 436 | void RinnaiSignalDecoder::writePacket(const byte pin, const byte *data, const byte len, const bool invert) 437 | { 438 | // send init 439 | gpio_set_level_IRAM(pin, HIGH ^ invert); 440 | delayMicroseconds(INIT_PULSE); 441 | gpio_set_level_IRAM(pin, LOW ^ invert); 442 | // send bytes 443 | for (byte i = 0; i < len; i++) 444 | { 445 | // send byte 446 | for (byte bit = 0; bit < 8; bit++) 447 | { 448 | // send bit 449 | const byte value = data[i] & (1 << bit); 450 | if (value) 451 | { 452 | delayMicroseconds(SHORT_PULSE); 453 | gpio_set_level_IRAM(pin, HIGH ^ invert); 454 | delayMicroseconds(LONG_PULSE); 455 | gpio_set_level_IRAM(pin, LOW ^ invert); 456 | } 457 | else 458 | { 459 | delayMicroseconds(LONG_PULSE); 460 | gpio_set_level_IRAM(pin, HIGH ^ invert); 461 | delayMicroseconds(SHORT_PULSE); 462 | gpio_set_level_IRAM(pin, LOW ^ invert); 463 | } 464 | } 465 | } 466 | } 467 | 468 | bool RinnaiSignalDecoder::setOverridePacket(const byte *data, int length) 469 | { 470 | if (length != BYTES_IN_PACKET) 471 | { 472 | return false; 473 | } 474 | // wait for high priority override task to complete 475 | while (isOverriding) 476 | ; 477 | 478 | if (overridePacketSet) // if we already set a packet but it didn't flush 479 | { 480 | return false; 481 | } 482 | 483 | memcpy(overridePacket, data, length); 484 | overridePacketSet = true; // turn on flag 485 | return true; 486 | } 487 | -------------------------------------------------------------------------------- /src/RinnaiMQTTGateway.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "LogStream.hpp" 5 | #include "RinnaiMQTTGateway.hpp" 6 | 7 | const bool REPORT_RESEARCH_FIELDS = true; // send some additional data in JSON to help us understand the protocol better 8 | const int MQTT_REPORT_FORCED_FLUSH_INTERVAL_MS = 20000; // ms 9 | const int STATE_JSON_MAX_SIZE = REPORT_RESEARCH_FIELDS ? 500 : 300; 10 | const int CONFIG_JSON_MAX_SIZE = 700; 11 | const int MAX_OVERRIDE_PERIOD_FROM_ORIGINAL_MS = 500; // ms, only send override if there was an original message lately 12 | 13 | RinnaiMQTTGateway::RinnaiMQTTGateway(String haDeviceName, RinnaiSignalDecoder &rxDecoder, RinnaiSignalDecoder &txDecoder, MQTTClient &mqttClient, String mqttTopic, byte testPin) 14 | : haDeviceName(haDeviceName), rxDecoder(rxDecoder), txDecoder(txDecoder), mqttClient(mqttClient), mqttTopic(mqttTopic), mqttTopicState(String(mqttTopic) + "/state"), testPin(testPin) 15 | { 16 | // set a will topic to signal that we are unavailable 17 | String availabilityTopic = mqttTopic + "/availability"; 18 | mqttClient.setWill(availabilityTopic.c_str(), "offline", true, 0); // set retained will message 19 | } 20 | 21 | void RinnaiMQTTGateway::loop() 22 | { 23 | // low level rinnai decoding monitoring 24 | if (logLevel == RAW) 25 | { 26 | logStream().printf("rx errors: pulse %d, bit %d, packet %d\n", rxDecoder.getPulseHandlerErrorCounter(), rxDecoder.getBitTaskErrorCounter(), rxDecoder.getPacketTaskErrorCounter()); 27 | logStream().printf("rx pulse: waiting %d, avail %d\n", uxQueueMessagesWaiting(rxDecoder.getPulseQueue()), uxQueueSpacesAvailable(rxDecoder.getPulseQueue())); 28 | logStream().printf("rx bit: waiting %d, avail %d\n", uxQueueMessagesWaiting(rxDecoder.getBitQueue()), uxQueueSpacesAvailable(rxDecoder.getBitQueue())); 29 | logStream().printf("rx packet: waiting %d, avail %d\n", uxQueueMessagesWaiting(rxDecoder.getPacketQueue()), uxQueueSpacesAvailable(rxDecoder.getPacketQueue())); 30 | 31 | logStream().printf("tx errors: pulse %d, bit %d, packet %d\n", txDecoder.getPulseHandlerErrorCounter(), txDecoder.getBitTaskErrorCounter(), txDecoder.getPacketTaskErrorCounter()); 32 | logStream().printf("tx pulse: waiting %d, avail %d\n", uxQueueMessagesWaiting(txDecoder.getPulseQueue()), uxQueueSpacesAvailable(txDecoder.getPulseQueue())); 33 | logStream().printf("tx bit: waiting %d, avail %d\n", uxQueueMessagesWaiting(txDecoder.getBitQueue()), uxQueueSpacesAvailable(txDecoder.getBitQueue())); 34 | logStream().printf("tx packet: waiting %d, avail %d\n", uxQueueMessagesWaiting(txDecoder.getPacketQueue()), uxQueueSpacesAvailable(txDecoder.getPacketQueue())); 35 | } 36 | // dump intermediate item queues for low level debug 37 | // might require to stop their organic consuming task in the signal decoder first 38 | /* 39 | static unsigned long lastPulseTime = 0; 40 | while (uxQueueMessagesWaiting(rxDecoder.getPulseQueue())) 41 | { 42 | PulseQueueItem item; 43 | BaseType_t ret = xQueueReceive(rxDecoder.getPulseQueue(), &item, 0); // pdTRUE=1 if an item was successfully received from the queue, otherwise pdFALSE. 44 | // hack delta 45 | unsigned long d = clockCyclesToMicroseconds(item.cycle - lastPulseTime); 46 | lastPulseTime = item.cycle; 47 | logStream().printf("rx p %d %d, q %d, r %d\n", item.newLevel, d, uxQueueMessagesWaiting(rxDecoder.getPulseQueue()), ret); 48 | } 49 | while (uxQueueMessagesWaiting(rxDecoder.getBitQueue())) 50 | { 51 | BitQueueItem item; 52 | BaseType_t ret = xQueueReceive(rxDecoder.getBitQueue(), &item, 0); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 53 | logStream().printf("rx b %d %d %d, q %d, r %d\n", item.bit, item.startCycle, item.misc, uxQueueMessagesWaiting(rxDecoder.getBitQueue()), ret); 54 | } 55 | */ 56 | while (uxQueueMessagesWaiting(rxDecoder.getPacketQueue())) 57 | { 58 | PacketQueueItem item; 59 | BaseType_t ret = xQueueReceive(rxDecoder.getPacketQueue(), &item, 0); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 60 | if (handleIncomingPacketQueueItem(item, true) == false) 61 | { 62 | logStream().printf("Error in rx pkt %d %02x%02x%02x %u %d %d %d, q %d, r %d\n", item.bitsPresent, item.data[0], item.data[1], item.data[2], item.startCycle, item.validPre, item.validParity, item.validChecksum, uxQueueMessagesWaiting(rxDecoder.getPacketQueue()), ret); 63 | } 64 | } 65 | 66 | while (uxQueueMessagesWaiting(txDecoder.getPacketQueue())) 67 | { 68 | PacketQueueItem item; 69 | BaseType_t ret = xQueueReceive(txDecoder.getPacketQueue(), &item, 0); // pdTRUE if an item was successfully received from the queue, otherwise pdFALSE. 70 | if (handleIncomingPacketQueueItem(item, false) == false) 71 | { 72 | logStream().printf("Error in tx pkt %d %02x%02x%02x %u %d %d %d, q %d, r %d\n", item.bitsPresent, item.data[0], item.data[1], item.data[2], item.startCycle, item.validPre, item.validParity, item.validChecksum, uxQueueMessagesWaiting(rxDecoder.getPacketQueue()), ret); 73 | } 74 | } 75 | 76 | // MQTT payload generation and flushing 77 | // render payload 78 | DynamicJsonDocument doc(STATE_JSON_MAX_SIZE); 79 | doc["ip"] = WiFi.localIP().toString(); 80 | doc["testPin"] = digitalRead(testPin) == LOW ? "ON" : "OFF"; 81 | doc["enableTemperatureSync"] = enableTemperatureSync; 82 | if (heaterPacketCounter) 83 | { 84 | doc["currentTemperature"] = lastHeaterPacketParsed.temperatureCelsius; 85 | doc["targetTemperature"] = targetTemperatureCelsius; 86 | doc["mode"] = lastHeaterPacketParsed.on ? "heat" : "off"; 87 | doc["action"] = lastHeaterPacketParsed.inUse ? "heating" : (lastHeaterPacketParsed.on ? "idle" : "off"); 88 | if (REPORT_RESEARCH_FIELDS) 89 | { 90 | doc["activeId"] = lastHeaterPacketParsed.activeId; 91 | doc["heaterBytes"] = RinnaiProtocolDecoder::renderPacket(lastHeaterPacketBytes); 92 | doc["startupState"] = lastHeaterPacketParsed.startupState; 93 | } 94 | } 95 | if (localControlPacketCounter && REPORT_RESEARCH_FIELDS) 96 | { 97 | doc["locControlId"] = lastLocalControlPacketParsed.myId; 98 | doc["locControlBytes"] = RinnaiProtocolDecoder::renderPacket(lastLocalControlPacketBytes); 99 | } 100 | String payload; 101 | serializeJson(doc, payload); 102 | // check if to send 103 | unsigned long now = millis(); 104 | if (mqttClient.connected() && (now - lastMqttReportMillis > MQTT_REPORT_FORCED_FLUSH_INTERVAL_MS || payload != lastMqttReportPayload)) 105 | { 106 | // now that we have decided to send, expand payload with additional fields that normally don't trigger a send on their own 107 | doc["rssi"] = WiFi.RSSI(); // the current RSSI /Received Signal Strength in dBm (?) 108 | if (REPORT_RESEARCH_FIELDS) 109 | { 110 | if (heaterPacketCounter) 111 | { 112 | doc["heaterDelta"] = lastHeaterPacketDeltaMillis; 113 | } 114 | if (localControlPacketCounter) 115 | { 116 | doc["locControlTiming"] = millisDeltaPositive(lastLocalControlPacketMillis, lastHeaterPacketMillis, lastHeaterPacketDeltaMillis); 117 | } 118 | if (remoteControlPacketCounter) 119 | { 120 | doc["remControlId"] = lastRemoteControlPacketParsed.myId; 121 | doc["remControlBytes"] = RinnaiProtocolDecoder::renderPacket(lastRemoteControlPacketBytes); 122 | doc["remControlTiming"] = millisDeltaPositive(lastRemoteControlPacketMillis, lastHeaterPacketMillis, lastHeaterPacketDeltaMillis); 123 | } 124 | if (unknownPacketCounter) 125 | { 126 | doc["unknownBytes"] = RinnaiProtocolDecoder::renderPacket(lastUnknownPacketBytes); 127 | doc["unknownTiming"] = millisDeltaPositive(lastUnknownPacketMillis, lastHeaterPacketMillis, lastHeaterPacketDeltaMillis); 128 | } 129 | } 130 | // re-serialize payload 131 | String payloadExpanded; 132 | serializeJson(doc, payloadExpanded); 133 | // send 134 | logStream().printf("Sending on MQTT channel '%s': %d/%d bytes, %s\n", mqttTopicState.c_str(), payloadExpanded.length(), STATE_JSON_MAX_SIZE, payloadExpanded.c_str()); 135 | bool ret = mqttClient.publish(mqttTopicState, payloadExpanded, true, 0); 136 | if (!ret) 137 | { 138 | logStream().println("Error publishing a state MQTT message"); 139 | } 140 | lastMqttReportMillis = now; 141 | lastMqttReportPayload = payload; // save last (restricted) payload for change detection 142 | } 143 | 144 | // delay to not over flood the serial interface 145 | // delay(100); 146 | } 147 | 148 | bool RinnaiMQTTGateway::handleIncomingPacketQueueItem(const PacketQueueItem &item, bool remote) 149 | { 150 | // check packet is valid 151 | if (!item.validPre || !item.validParity || !item.validChecksum) 152 | { 153 | return false; 154 | } 155 | // see where the packet originates from 156 | RinnaiPacketSource source = RinnaiProtocolDecoder::getPacketSource(item.data, RinnaiSignalDecoder::BYTES_IN_PACKET); 157 | if (source == INVALID) // bad checksum, size, etc 158 | { 159 | return false; 160 | } 161 | else if (source == HEATER && remote) 162 | { 163 | RinnaiHeaterPacket packet; 164 | bool ret = RinnaiProtocolDecoder::decodeHeaterPacket(item.data, packet); 165 | if (!ret) 166 | { 167 | return false; 168 | } 169 | memcpy(&lastHeaterPacketParsed, &packet, sizeof(RinnaiHeaterPacket)); 170 | memcpy(lastHeaterPacketBytes, item.data, RinnaiProtocolDecoder::BYTES_IN_PACKET); 171 | // counters and timings 172 | unsigned long t = item.startMillis; 173 | if (heaterPacketCounter > 0) 174 | { 175 | lastHeaterPacketDeltaMillis = t - lastHeaterPacketMillis; // measure cycle period 176 | } 177 | heaterPacketCounter++; 178 | lastHeaterPacketMillis = t; 179 | // init target temperature once we have reports from the heater 180 | if (targetTemperatureCelsius == -1) 181 | { 182 | targetTemperatureCelsius = lastHeaterPacketParsed.temperatureCelsius; 183 | } 184 | // act on temperature info 185 | handleTemperatureSync(); 186 | // log 187 | if (logLevel == PARSED) 188 | { 189 | logStream().printf("Heater packet: a=%d o=%d u=%d t=%d\n", packet.activeId, packet.on, packet.inUse, packet.temperatureCelsius); 190 | } 191 | } 192 | else if (source == CONTROL) 193 | { 194 | RinnaiControlPacket packet; 195 | bool ret = RinnaiProtocolDecoder::decodeControlPacket(item.data, packet); 196 | if (!ret) 197 | { 198 | return false; 199 | } 200 | if (remote) 201 | { 202 | memcpy(&lastRemoteControlPacketParsed, &packet, sizeof(RinnaiControlPacket)); 203 | memcpy(lastRemoteControlPacketBytes, item.data, RinnaiProtocolDecoder::BYTES_IN_PACKET); 204 | remoteControlPacketCounter++; 205 | lastRemoteControlPacketMillis = item.startMillis; 206 | } 207 | else 208 | { 209 | memcpy(&lastLocalControlPacketParsed, &packet, sizeof(RinnaiControlPacket)); 210 | memcpy(lastLocalControlPacketBytes, item.data, RinnaiProtocolDecoder::BYTES_IN_PACKET); 211 | localControlPacketCounter++; 212 | lastLocalControlPacketMillis = item.startMillis; 213 | } 214 | // log 215 | if (logLevel == PARSED) 216 | { 217 | logStream().printf("Control packet: r=%d i=%d o=%d p=%d td=%d tu=%d\n", remote, packet.myId, packet.onOffPressed, packet.priorityPressed, packet.temperatureDownPressed, packet.temperatureUpPressed); 218 | } 219 | } 220 | else // source == UNKNOWN || local HEATER 221 | { 222 | // save metrics for troubleshooting and research 223 | memcpy(lastUnknownPacketBytes, item.data, RinnaiProtocolDecoder::BYTES_IN_PACKET); 224 | unknownPacketCounter++; 225 | lastUnknownPacketMillis = item.startMillis; 226 | } 227 | return true; 228 | } 229 | 230 | void RinnaiMQTTGateway::handleTemperatureSync() 231 | { 232 | if (heaterPacketCounter && localControlPacketCounter && targetTemperatureCelsius != -1 && 233 | lastHeaterPacketParsed.temperatureCelsius != targetTemperatureCelsius && millis() - lastHeaterPacketMillis < MAX_OVERRIDE_PERIOD_FROM_ORIGINAL_MS) 234 | { 235 | override(lastHeaterPacketParsed.temperatureCelsius < targetTemperatureCelsius ? TEMPERATURE_UP : TEMPERATURE_DOWN); 236 | } 237 | } 238 | 239 | bool RinnaiMQTTGateway::override(OverrideCommand command) 240 | { 241 | // check if state is valid for sending 242 | unsigned long originalControlPacketAge = millis() - lastLocalControlPacketMillis; 243 | if (originalControlPacketAge > MAX_OVERRIDE_PERIOD_FROM_ORIGINAL_MS) // if we have no recent original packet. can happen because no panel signal is available 244 | { 245 | logStream().printf("No fresh original data for override command %d, age %lu, millis %lu, lastLocal %lu\n", command, originalControlPacketAge, millis(), lastLocalControlPacketMillis); 246 | return false; 247 | } 248 | // logStream().printf("Attempting override command %d, age %d\n", command, originalControlPacketAge); 249 | // prep buffer 250 | byte buf[RinnaiSignalDecoder::BYTES_IN_PACKET]; 251 | memcpy(buf, lastLocalControlPacketBytes, RinnaiSignalDecoder::BYTES_IN_PACKET); 252 | switch (command) 253 | { 254 | case ON_OFF: 255 | RinnaiProtocolDecoder::setOnOffPressed(buf); 256 | break; 257 | case PRIORITY: 258 | RinnaiProtocolDecoder::setPriorityPressed(buf); 259 | break; 260 | case TEMPERATURE_UP: 261 | RinnaiProtocolDecoder::setTemperatureUpPressed(buf); 262 | break; 263 | case TEMPERATURE_DOWN: 264 | RinnaiProtocolDecoder::setTemperatureDownPressed(buf); 265 | break; 266 | default: 267 | logStream().println("Unknown command for override"); 268 | return false; 269 | } 270 | bool overRet = txDecoder.setOverridePacket(buf, RinnaiSignalDecoder::BYTES_IN_PACKET); 271 | if (overRet == false) 272 | { 273 | logStream().printf("Error setting override, command = %d\n", command); // are we hammering too fast? 274 | return false; 275 | } 276 | return true; 277 | } 278 | 279 | void RinnaiMQTTGateway::onMqttMessageReceived(String &fullTopic, String &payload) 280 | { 281 | // parse topic 282 | String topic; 283 | int index = fullTopic.lastIndexOf('/'); 284 | if (index != -1) 285 | { 286 | topic = fullTopic.substring(index + 1); 287 | } 288 | else 289 | { 290 | topic = fullTopic; 291 | } 292 | 293 | // ignore what we send 294 | if (topic == "config" || topic == "state" || topic == "availability") 295 | { 296 | return; 297 | } 298 | 299 | // log 300 | logStream().printf("Incoming: %s %s - %s\n", fullTopic.c_str(), topic.c_str(), payload.c_str()); 301 | 302 | // handle command 303 | if (topic == "temp") 304 | { 305 | // parse and verify targetTemperature 306 | int temp = atoi(payload.c_str()); 307 | temp = min(temp, (int)RinnaiProtocolDecoder::TEMP_C_MAX); 308 | temp = max(temp, (int)RinnaiProtocolDecoder::TEMP_C_MIN); 309 | logStream().printf("Setting %d as target temperature\n", temp); 310 | targetTemperatureCelsius = temp; 311 | } 312 | else if (topic == "temperature_sync") 313 | { 314 | if (payload == "on" || payload == "enable" || payload == "true" || payload == "1") 315 | { 316 | enableTemperatureSync = true; 317 | } 318 | else 319 | { 320 | enableTemperatureSync = false; // in case something breaks and you want to take control manually at the panel 321 | } 322 | } 323 | else if (topic == "mode") 324 | { 325 | if ((payload == "off" && lastHeaterPacketParsed.on) || (payload == "heat" && !lastHeaterPacketParsed.on)) 326 | { 327 | override(ON_OFF); 328 | } 329 | } 330 | else if (topic == "priority") 331 | { 332 | override(PRIORITY); 333 | } 334 | else if (topic == "log_level") 335 | { 336 | if (payload == "none") 337 | { 338 | logLevel = NONE; 339 | } 340 | else if (payload == "parsed") 341 | { 342 | logLevel = PARSED; 343 | } 344 | else if (payload == "raw") 345 | { 346 | logLevel = RAW; 347 | } 348 | } 349 | else if (topic == "log_destination") 350 | { 351 | if (payload == "telnet") 352 | { 353 | logStream().println("Telnet log set"); 354 | logStream.SetLogStreamTelnet(); 355 | } 356 | else 357 | { 358 | logStream().println("Serial log set"); 359 | logStream.SetLogStreamSerial(); 360 | } 361 | } 362 | else 363 | { 364 | logStream().printf("Unknown topic: %s\n", topic.c_str()); 365 | } 366 | } 367 | 368 | void RinnaiMQTTGateway::onMqttConnected() 369 | { 370 | // subscribe 371 | bool ret = mqttClient.subscribe(mqttTopic + "/#"); 372 | if (!ret) 373 | { 374 | logStream().println("Error doing a MQTT subscribe"); 375 | } 376 | 377 | // send a '/config' topic to achieve MQTT discovery - https://www.home-assistant.io/docs/mqtt/discovery/ 378 | DynamicJsonDocument doc(CONFIG_JSON_MAX_SIZE); 379 | doc["~"] = mqttTopic; 380 | doc["name"] = haDeviceName; 381 | doc["action_topic"] = "~/state"; 382 | doc["action_template"] = "{{ value_json.action }}"; 383 | doc["current_temperature_topic"] = "~/state"; 384 | doc["current_temperature_template"] = "{{ value_json.currentTemperature }}"; 385 | doc["max_temp"] = RinnaiProtocolDecoder::TEMP_C_MAX; 386 | doc["min_temp"] = RinnaiProtocolDecoder::TEMP_C_MIN; 387 | doc["initial"] = RinnaiProtocolDecoder::TEMP_C_MIN; 388 | doc["mode_command_topic"] = "~/mode"; 389 | doc["mode_state_topic"] = "~/state"; 390 | doc["mode_state_template"] = "{{ value_json.mode }}"; 391 | doc["modes"][0] = "off"; 392 | doc["modes"][1] = "heat"; 393 | doc["precision"] = 1; 394 | doc["temperature_command_topic"] = "~/temp"; 395 | doc["temperature_unit"] = "C"; 396 | doc["temperature_state_topic"] = "~/state"; 397 | doc["temperature_state_template"] = "{{ value_json.targetTemperature }}"; 398 | doc["availability_topic"] = "~/availability"; 399 | String payload; 400 | serializeJson(doc, payload); 401 | logStream().printf("Sending on MQTT channel '%s/config': %d/%d bytes, %s\n", mqttTopic.c_str(), payload.length(), CONFIG_JSON_MAX_SIZE, payload.c_str()); 402 | ret = mqttClient.publish(mqttTopic + "/config", payload, true, 0); 403 | if (!ret) 404 | { 405 | logStream().println("Error publishing a config MQTT message"); 406 | } 407 | // send an availability topic to signal that we are available 408 | ret = mqttClient.publish(mqttTopic + "/availability", "online", true, 0); 409 | if (!ret) 410 | { 411 | logStream().println("Error publishing an availability MQTT message"); 412 | } 413 | } 414 | 415 | long RinnaiMQTTGateway::millisDelta(unsigned long t1, unsigned long t2) 416 | { 417 | // how to do it even better to handle more edge cases, overflow of millis and unsigned/signed issues? 418 | if (t1 > t2) 419 | { 420 | return t1 - t2; 421 | } 422 | else 423 | { 424 | return - (t2 - t1); 425 | } 426 | } 427 | 428 | // try to calculate a positive delta in a scenario where events are expected to come in a recurring cyclic manner 429 | long RinnaiMQTTGateway::millisDeltaPositive(unsigned long t1, unsigned long t2, unsigned long cycle) 430 | { 431 | long d = millisDelta(t1, t2); 432 | if (d < 0) 433 | { 434 | return d + cycle; 435 | } 436 | return d; 437 | } --------------------------------------------------------------------------------