diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c695690 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +secrets.ini +logs/* \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/.vscode/ipch/59a9facdc0666fbe/mmap_address.bin b/.vscode/ipch/59a9facdc0666fbe/mmap_address.bin deleted file mode 100644 index 71307ab..0000000 Binary files a/.vscode/ipch/59a9facdc0666fbe/mmap_address.bin and /dev/null differ diff --git a/.vscode/ipch/af4c1ea3071889c8/mmap_address.bin b/.vscode/ipch/af4c1ea3071889c8/mmap_address.bin deleted file mode 100644 index 71307ab..0000000 Binary files a/.vscode/ipch/af4c1ea3071889c8/mmap_address.bin and /dev/null differ diff --git a/.vscode/ipch/af4c1ea3071889c8/nilan_code.ipch b/.vscode/ipch/af4c1ea3071889c8/nilan_code.ipch deleted file mode 100644 index bbf718a..0000000 Binary files a/.vscode/ipch/af4c1ea3071889c8/nilan_code.ipch and /dev/null differ diff --git a/.vscode/ipch/ffb8356500484687/mmap_address.bin b/.vscode/ipch/ffb8356500484687/mmap_address.bin deleted file mode 100644 index 71307ab..0000000 Binary files a/.vscode/ipch/ffb8356500484687/mmap_address.bin and /dev/null differ diff --git a/.vscode/ipch/ffb8356500484687/nilan_code.ipch b/.vscode/ipch/ffb8356500484687/nilan_code.ipch deleted file mode 100644 index abb20d9..0000000 Binary files a/.vscode/ipch/ffb8356500484687/nilan_code.ipch and /dev/null differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7566973 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,53 @@ +{ + "cSpell.words": [ + "chipid", + "dtostrf", + "Modbus", + "Openhab", + "platformio" + ], + "cSpell.ignoreWords": [ + "Gunvald", + "Anders", + "Kvist", + "Scherrebeck", + "Wemos", + "Benoît", + "Blanchon", + "ventset", + "runset", + "modeset", + "tempset", + "programset", + "OVERTEMP", + "LEGIONEL", + "INSTABUS", + "reqtemp", + "reqalarm", + "reqtime", + "reqcontrol", + "reqspeed", + "reqairtemp", + "reqairflow", + "reqairheat", + "reqprogram", + "requser", + "requser2", + "reqinfo", + "reqinputairtemp", + "reqapp", + "reqoutput", + "reqdisplay1", + "reqdisplay2", + "reqdisplay", + "reqmax", + "airtemp", + "airheat", + "inputairtemp", + "Frost_overht", + "SWSERIAL_8E1", + "weekprogram" + + + ] +} \ No newline at end of file diff --git a/CTS602_w_HMI350T_Modbus.pdf b/CTS602_w_HMI350T_Modbus.pdf new file mode 100644 index 0000000..8035509 Binary files /dev/null and b/CTS602_w_HMI350T_Modbus.pdf differ diff --git a/Esphome/nilan_display.png b/Esphome/nilan_display.png index b830bfe..c96f494 100644 Binary files a/Esphome/nilan_display.png and b/Esphome/nilan_display.png differ diff --git a/Home Assistant/HA_GUI.png b/Home Assistant/HA_GUI.png index 8169d8a..77e000e 100644 Binary files a/Home Assistant/HA_GUI.png and b/Home Assistant/HA_GUI.png differ diff --git a/Home Assistant/ventilation.png b/Home Assistant/ventilation.png index fab0cdb..1434d07 100644 Binary files a/Home Assistant/ventilation.png and b/Home Assistant/ventilation.png differ diff --git a/Openhab/openhabV1.items b/Openhab/openhabV1.items new file mode 100644 index 0000000..68c6316 --- /dev/null +++ b/Openhab/openhabV1.items @@ -0,0 +1,10 @@ +Switch nilanrun {mqtt=">[my:ventilation/RunSet:command:ON:1],>[my:ventilation/RunSet:command:OFF:0]"} +Number nilanrunstate {mqtt="<[my:ventilation/control/RunSet:state:REGEX(([0-9-]+))]"} +Number nilanmode {mqtt=">[my:ventilation/ModeSet:state::default]"} +Number nilanmodestate {mqtt="<[my:ventilation/control/ModeSet:state:REGEX(([0-9-]+))]"} +Number nilanvent {mqtt=">[my:ventilation/VentSet:state::default]"} +Number nilanventstate {mqtt="<[my:ventilation/control/VentSet:state:REGEX(([0-9-]+))]"} +Number nilantemp {mqtt=">[my:ventilation/TempSet:state::REGEX(([0-9-]+))]"} +Number nilantempstate {mqtt="<[my:ventilation/control/TempSet:state:REGEX(([0-9-]+))]"} +Number nilaninlet "Ventilation indgang temp [%s]" {mqtt="<[my:temp/T7_Inlet:state:REGEX(.?([0-9.]+).]"} +Number nilanoutside "Ventilation ude temp [%s]" {mqtt="<[my:temp/T8_Outdoor:state:REGEX(.?([0-9.]+).*]"} \ No newline at end of file diff --git a/Openhab/openhabv1.sitemap b/Openhab/openhabv1.sitemap new file mode 100644 index 0000000..da52dbe --- /dev/null +++ b/Openhab/openhabv1.sitemap @@ -0,0 +1,6 @@ +Frame label="Ventilation" { +Switch item=nilanrun label="Tændt" +Selection item=nilanmode label="Funktion" mappings=[0= "Off", "1"="Heat", "2" = "Cool", 3 = "Auto", 4 = "Service"] +Selection item=nilantemp label="Temperatur" mappings=[1900="19", 2200="22", 2500="25"] +Selection item=nilanvent label="Hastighed" mappings=[0="0",1="1",2="2",3="3",4="4"] +} \ No newline at end of file diff --git a/README.md b/README.md index 758c7e4..534e6b1 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,49 @@ -# Make your Nilan Air ventilating system way more cool ;) +# Unofficial gateway for Nilan ventilation system -This little cool project lets you use you Home Assistant to control and read values from your Nilan air vent system. I have the Nilan Comfort 300 combined with the CTS602 panel. It works great, but do not know if it is compatible with other models. +This little cool project lets you control your Nilan air vent system. Eg. Used with Nilan Comfort 300 LR combined with the CTS602 panel. And should be compatible with other models. -The code for the project is not developed by me, but I made some adjustmenst to it, so it would integrate better with Home Assistant. The project is originally made for use with OpenHab. +Can be used with Openhab, Home Assistant, Node-red or anything else your heart desires. -For the original project look here: https://github.com/DanGunvald/NilanModbus +Easy compile source code via Platform.io setup. -Please proceed this project at your own risk!!! +![SVG preview of system](images/overview.svg) -UPDATE 19/2-2020 : Got the code working with ArduinoJson version 6 (updated from version 5). Version 6 had some big breaking changes. +This is a fork of https://github.com/jascdk/Nilan_Homeassistant mainly made to support platform.io. -UPDATE 1/1-2020 : Now added a .ino file for use with a Nilan VPL15 system. Creates some others sensors over the Comfort 300 system. thanks to Martin Skeem for editing / coding :) +Which in turn is a fork of https://github.com/DanGunvald/NilanModbus -## Okay lets get to it! +# How to use: +You can use this either via MQTT messages or via web request -### Installing the firmware: - -I used the arduino editor to upload the code to my ESP8266 (for now a wemos D1 mini). If your sketch wont compile please check if you use the arduino.json V. 5 or V.6 library. This code uses V.6 and wont build with V.5. - -For setting up your wifi and mqtt broker provide your credentials in the configuration.h file - - -### Setup the Hardware: +## Web +You can get some json values from the Nilan by calling to it via HTTP. Just use your browser and type: -For my project i use af Wemos D1 mini board connected to a RS485 board (bought form ali-express). You connect from the Wemos the RX to RX on the RS485 and the TX to TX. This wont work if you cross them. +`http://[ip]/help` - This should give you som registers -Then connect the RS485 A,B and GND channel to the corresponding ports on you Nilan Vent System. +`http://[ip]/read/app` - This would for example give you some status of the output -### Getting values by HTTP: +`http://[ip]/get/[adress]/[amountOfAdresessToRead]/[0=InputRegister(default),1=HoldingRegister]`- This would make you able to read raw data from controller -You can get some json values from the Nilan by calling to it via HTTP. Just use your browser and type: +`http://[ip]/set/[group]/[adress]/[value]`- This would make you able to send commands through HTTP -`DEVICE` - corresponds to the IP adress you you device (esp8266) -`http://[device]/help` - This should give you som registers -`http://[device]/read/output` - This would for example give you some status of the output +e.g -`http://[device]/set/[group]/[adress]/[value]`- This would make you able to send commands through HTTP +`http://10.0.1.16/read/app` This is a great starter to give you info about the modbus connection being ok as this reads from the safest area of the modbus registers. Other commands might fail as controller don't know the status of the requested index e.g. if sensor is not connected or optional board is not connected. -e.g +`http://10.0.1.16/get/610/6/1` Will return read values of addresses 610-615 in holding register range. `http://10.0.1.16/set/control/1004/2700` This will set your temperature to 27 degrees. -### Getting values by MQTT: +## Getting values by MQTT: -Here is where it all shines - the code puts out som useful MQTT topics to monitor different thing. +There is a lot of topics to be found here. I recommend using "MQTT Explorer" to se what is published. -Any MQTT-Tool (I use on my mac a tool called "MQTT Box") can be used to get the values by subscribing to : +### Listen + +Here are some to listen on: `ventilation/temp/#`- This will give the temperatures from all the sensors. @@ -56,34 +51,41 @@ Any MQTT-Tool (I use on my mac a tool called "MQTT Box") can be used to get the `ventilation/#` - This gives the output of the system - fan speed etc. Remember the payloads are given in values not text. -### Integrate with Home Assistant. - -For my integration i use a package with all my Nilan config yaml in just one file. The file can be downloaded above (config.yaml). - -After a restart of Home Assistant you will get alot of new sensors. These can be integrated in Home Assistant in different ways. I use the integrated Lovelace UI to make my UI. You can see below, how it can look like:) - -![HA_GUI](https://github.com/jascdk/Nilan_Homeassistant/blob/master/Home%20Assistant/HA_GUI.png) - -### Making External displays, that shows you the Nilan Data: - -I have tried to make some LCD´s using some 4x16 rows displays together with an ESP32 running ESP-Home www.esphome.io . - -If you wanna try it out you can use my provided .yaml code for ESP-Home above:) - -### SPECIAL THANKS for contribution to this project goes to: @anderskvist https://github.com/anderskvist :) - - - +### Write back +Here are all commands you are able to send back for controlling it. I recommend sending the commands as retained messages to make sure that any faults or reboot of the controller does not affect the outcome. Retained messages are cleared once the command is accepted. +| Command | Input |Description | +| --- |---| ---| +|`ventilation/cmd/ventset`| 0-4 | Set ventilation speed | +|`ventilation/cmd/modeset`| 0-4 |Actual operation mode.0=Off, 1=Heat, 2=Cool, 3=Auto, 4=Service | +|`ventilation/cmd/runset`| 0-1 | User on / off select (equal to ON/OFF keys) | +|`ventilation/cmd/tempset`| 500-2500 | Set temperature to celsius * 100 | +|`ventilation/cmd/programset`| 0 - 4 | Start week program index | +|`ventilation/cmd/update`| 1 | Gateway has OTA active always but can be hard to reach if sometime. This puts gateway into OTA update mode for 60 seconds. | +|`ventilation/cmd/reboot`| 1 | Reboots gateway | +|`ventilation/cmd/version`| 1 | Reports compiled date back | +# Installation +Should run on most ESP8266 boards like: wemos D1 mini or nodeMCU. +## Config +Edit configuration.h file to your liking including settings for wifi and mqtt broker. +## Upload to hardware +I recommend using platform IO https://platformio.org/ inside Visual Studio Code as dependencies will be downloaded automatic in most cases due to the `platformio.ini` file. +## Make electrical connection +You can use both a hardware interface or a software one. In theory they both should give the same result but I tent to use the hardware one in production setup and the software one during debugging to allow debug messages via serial port. +Connect Tx of ESP to Rx on RS485 board. And Rx of ESP to Tx of RS485 board. +RS485 I used: [MAX3485 Module TTL To RS485 Module MCU](https://www.aliexpress.com/item/32828100565.html) +![RS485-board.JPG](images/RS485-board.JPG) +This is my setup with the Nilan HMI still connected and working fine: +![connection.JPG](images/connection.JPG) diff --git a/images/RS485-board.JPG b/images/RS485-board.JPG new file mode 100644 index 0000000..b9c6c7c Binary files /dev/null and b/images/RS485-board.JPG differ diff --git a/images/connection.JPG b/images/connection.JPG new file mode 100644 index 0000000..5a946f3 Binary files /dev/null and b/images/connection.JPG differ diff --git a/images/nilan-plug.jpg b/images/nilan-plug.jpg new file mode 100644 index 0000000..06efe58 Binary files /dev/null and b/images/nilan-plug.jpg differ diff --git a/images/nilan-terminals.JPG b/images/nilan-terminals.JPG new file mode 100644 index 0000000..a1a1fc9 Binary files /dev/null and b/images/nilan-terminals.JPG differ diff --git a/images/nodemcu.JPG b/images/nodemcu.JPG new file mode 100644 index 0000000..e5318d2 Binary files /dev/null and b/images/nodemcu.JPG differ diff --git a/images/overview.svg b/images/overview.svg new file mode 100644 index 0000000..4622dca --- /dev/null +++ b/images/overview.svg @@ -0,0 +1 @@ +T8VentT4T3T7RHBypassTemp \ No newline at end of file diff --git a/include/README b/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..6debab1 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in a an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/nilan_code/configuration.h b/nilan_code/configuration.h deleted file mode 100644 index 08dfee8..0000000 --- a/nilan_code/configuration.h +++ /dev/null @@ -1,31 +0,0 @@ -/* - * It should not be necessary to change anything else than in this file to suit your needs. - * If you need to change other things, consider adding to this file and submit a pull request :) - * Remember that all password, ssid´s and so on are CASE SENSITIVE ! - */ - -// Change config to verify that project has been configured -#define CONFIGURED 0 // Change this to "1" when you completed your config and begin upload - -// WIFI settings -#define WIFISSID "XX" // Put in your SSID -#define WIFIPASSWORD "XX" // Put in you SSID Password -#define CUSTOM_HOSTNAME "Nilan_MQTT" // Hostname of the ESP8266 so that it's easier to find in your DHCP range - -// LED settings -#define WIFI_LED 2 // Blue led on NodeMCU -#define USE_WIFI_LED false // if 'true', the blue led in a NodeMCU will blink during connection, - // and glow solid once connected -// MQTT settings -#define MQTTSERVER "XX" // Put in the IP addresses of your MQTT broker -#define MQTTUSERNAME NULL // Username for the MQTT broker (NULL if no username is required) -#define MQTTPASSWORD NULL // Password for the MQTT broker (NULL if no password is required) - -// Serial port -#define SERIAL SERIAL_HARDWARE // SERIAL_SOFTWARE or SERIAL_HARDWARE -#define SERIAL_SOFTWARE_RX D2 // only needed if SERIAL is SERIAL_SOFTWARE -#define SERIAL_SOFTWARE_TX D1 // only needed if SERIAL is SERIAL_SOFTWARE - -#if CONFIGURED == 0 - #error "Default configuration used - won't upload to avoid loosing connection." -#endif diff --git a/nilan_code/nilan_code.ino b/nilan_code/nilan_code.ino deleted file mode 100644 index 6c81b68..0000000 --- a/nilan_code/nilan_code.ino +++ /dev/null @@ -1,626 +0,0 @@ -/** - Nilan Modbus firmware for D1 Mini (ESP8266) together with a TTL to RS485 Converter https://www.aliexpress.com/item/32836213346.html?spm=a2g0s.9042311.0.0.27424c4dqnr5i7 - - Written by Dan Gunvald - https://github.com/DanGunvald/NilanModbus - - Modified to use with Home Assistant by Anders Kvist, Jacob Scherrebeck and other great people :) - https://github.com/anderskvist/Nilan_Homeassistant - https://github.com/jascdk/Nilan_Homeassistant - - Read from a Nilan Air Vent System (Danish Brand) using a Wemos D1 - Mini (or other ESP8266-based board) and report the values to an MQTT - broker. Then use it for your home-automation system like Home Assistant. - - External dependencies. Install using the Arduino library manager: - - "Arduino JSON V6 by Benoît Blanchon https://github.com/bblanchon/ArduinoJson - IMPORTANT - Use latest V.6 !!! This code won´t compile with V.5 - "ModbusMaster by Doc Walker https://github.com/4-20ma/ModbusMaster - "PubSubClient" by Nick O'Leary https://github.com/knolleary/pubsubclient - - Project inspired by https://github.com/DanGunvald/NilanModbus - - Join this Danish Facebook Page for inspiration :) https://www.facebook.com/groups/667765647316443/ -*/ - -#include -#include -#include -#include -#include -#include "configuration.h" -#define SERIAL_SOFTWARE 1 -#define SERIAL_HARDWARE 2 -#if SERIAL == SERIAL_SOFTWARE -#include -#endif -#define HOST "NilanGW-%s" // Change this to whatever you like. -#define MAXREGSIZE 26 -#define SENDINTERVAL 60000 // normally set to 180000 milliseconds = 3 minutes. Define as you like -#define VENTSET 1003 -#define RUNSET 1001 -#define MODESET 1002 -#define TEMPSET 1004 -#define PROGRAMSET 500 - -#if SERIAL == SERIAL_SOFTWARE -SoftwareSerial SSerial(SERIAL_SOFTWARE_RX, SERIAL_SOFTWARE_TX); // RX, TX -#endif - -const char *ssid = WIFISSID; -const char *password = WIFIPASSWORD; -char chipid[12]; -const char *mqttserver = MQTTSERVER; -const char *mqttusername = MQTTUSERNAME; -const char *mqttpassword = MQTTPASSWORD; -WiFiServer server(80); -WiFiClient client; -PubSubClient mqttclient(client); -static long lastMsg = -SENDINTERVAL; -static int16_t rsbuffer[MAXREGSIZE]; -ModbusMaster node; - -int16_t AlarmListNumber[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 70, 71, 90, 91, 92}; -String AlarmListText[] = {"NONE", "HARDWARE", "TIMEOUT", "FIRE", "PRESSURE", "DOOR", "DEFROST", "FROST", "FROST", "OVERTEMP", "OVERHEAT", "AIRFLOW", "THERMO", "BOILING", "SENSOR", "ROOM LOW", "SOFTWARE", "WATCHDOG", "CONFIG", "FILTER", "LEGIONEL", "POWER", "T AIR", "T WATER", "T HEAT", "MODEM", "INSTABUS", "T1SHORT", "T1OPEN", "T2SHORT", "T2OPEN", "T3SHORT", "T3OPEN", "T4SHORT", "T4OPEN", "T5SHORT", "T5OPEN", "T6SHORT", "T6OPEN", "T7SHORT", "T7OPEN", "T8SHORT", "T8OPEN", "T9SHORT", "T9OPEN", "T10SHORT", "T10OPEN", "T11SHORT", "T11OPEN", "T12SHORT", "T12OPEN", "T13SHORT", "T13OPEN", "T14SHORT", "T14OPEN", "T15SHORT", "T15OPEN", "T16SHORT", "T16OPEN", "ANODE", "EXCH INFO", "SLAVE IO", "OPT IO", "PRESET", "INSTABUS"}; - -String req[4]; // operation, group, address, value -enum reqtypes -{ - reqtemp = 0, - reqalarm, - reqtime, - reqcontrol, - reqspeed, - reqairtemp, - reqairflow, - reqairheat, - reqprogram, - requser, - requser2, - reqinfo, - reqinputairtemp, - reqapp, - reqoutput, - reqdisplay1, - reqdisplay2, - reqdisplay, - reqmax -}; - -String groups[] = {"temp", "alarm", "time", "control", "speed", "airtemp", "airflow", "airheat", "program", "user", "user2", "info", "inputairtemp", "app", "output", "display1", "display2", "display"}; -byte regsizes[] = {23, 10, 6, 8, 2, 6, 2, 0, 1, 6, 6, 14, 7, 4, 26, 4, 4, 1}; -int regaddresses[] = {200, 400, 300, 1000, 200, 1200, 1100, 0, 500, 600, 610, 100, 1200, 0, 100, 2002, 2007, 3000}; -byte regtypes[] = {8, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 1, 4, 4, 8}; -char const *regnames[][MAXREGSIZE] = { - // temp - {"T0_Controller", NULL, NULL, "T3_Exhaust", "T4_Outlet", NULL, NULL, "T7_Inlet", "T8_Outdoor", NULL, NULL, NULL, NULL, NULL, NULL, "T15_Room", NULL, NULL, NULL, NULL, NULL, "RH", NULL}, - // alarm - {"Status", "List_1_ID", "List_1_Date", "List_1_Time", "List_2_ID", "List_2_Date", "List_2_Time", "List_3_ID", "List_3_Date", "List_3_Time"}, - // time - {"Second", "Minute", "Hour", "Day", "Month", "Year"}, - // control - {"Type", "RunSet", "ModeSet", "VentSet", "TempSet", "ServiceMode", "ServicePct", "Preset"}, - // speed - {"ExhaustSpeed", "InletSpeed"}, - // airtemp - {"CoolSet", "TempMinSum", "TempMinWin", "TempMaxSum", "TempMaxWin", "TempSummer"}, - // airflow - {"AirExchMode", "CoolVent"}, - // airheat - {}, - // program - {"Program"}, - // program.user - {"UserFuncAct", "UserFuncSet", "UserTimeSet", "UserVentSet", "UserTempSet", "UserOffsSet"}, - // program.user2 requires the optional print board - {"User2FuncAct", "User2FuncSet", "User2TimeSet", "User2VentSet", "User2TempSet", "User2OffsSet"}, - // info - {"UserFunc", "AirFilter", "DoorOpen", "Smoke", "MotorThermo", "Frost_overht", "AirFlow", "P_Hi", "P_Lo", "Boil", "3WayPos", "DefrostHG", "Defrost", "UserFunc_2"}, - // inputairtemp - {"IsSummer", "TempInletSet", "TempControl", "TempRoom", "EffPct", "CapSet", "CapAct"}, - // app - {"Bus.Version", "VersionMajor", "VersionMinor", "VersionRelease"}, - // output - {"AirFlap", "SmokeFlap", "BypassOpen", "BypassClose", "AirCircPump", "AirHeatAllow", "AirHeat_1", "AirHeat_2", "AirHeat_3", "Compressor", "Compressor_2", "4WayCool", "HotGasHeat", "HotGasCool", "CondOpen", "CondClose", "WaterHeat", "3WayValve", "CenCircPump", "CenHeat_1", "CenHeat_2", "CenHeat_3", "CenHeatExt", "UserFunc", "UserFunc_2", "Defrosting"}, - // display1 - {"Text_1_2", "Text_3_4", "Text_5_6", "Text_7_8"}, - // display2 - {"Text_9_10", "Text_11_12", "Text_13_14", "Text_15_16"}, - // airbypass - {"AirBypass/IsOpen"}}; - -char const *getName(reqtypes type, int address) -{ - if (address >= 0 && address <= regsizes[type]) - { - return regnames[type][address]; - } - return NULL; -} - -char WriteModbus(uint16_t addr, int16_t val) -{ - node.setTransmitBuffer(0, val); - char result = 0; - result = node.writeMultipleRegisters(addr, 1); - return result; -} -char ReadModbus(uint16_t addr, uint8_t sizer, int16_t *vals, int type) -{ - char result = 0; - switch (type) - { - case 0: - result = node.readInputRegisters(addr, sizer); - break; - case 1: - result = node.readHoldingRegisters(addr, sizer); - break; - } - if (result == node.ku8MBSuccess) - { - for (int j = 0; j < sizer; j++) - { - vals[j] = node.getResponseBuffer(j); - } - return result; - } - return result; -} - -JsonObject HandleRequest(JsonDocument &doc) -{ - JsonObject root = doc.to(); - reqtypes r = reqmax; - if (req[1] != "") - { - for (int i = 0; i < reqmax; i++) - { - if (groups[i] == req[1]) - { - r = (reqtypes)i; - } - } - } - char type = regtypes[r]; - if (req[0] == "read") - { - int address = 0; - int nums = 0; - char result = -1; - address = regaddresses[r]; - nums = regsizes[r]; - - result = ReadModbus(address, nums, rsbuffer, type & 1); - if (result == 0) - { - root["status"] = "Modbus connection OK"; - for (int i = 0; i < nums; i++) - { - char const *name = getName(r, i); - if (name != NULL && strlen(name) > 0) - { - if ((type == 2 && i > 0) || type == 4) - { - String str = ""; - str += (char)(rsbuffer[i] & 0x00ff); - str = (char)(rsbuffer[i] >> 8) + str; // Remove leading space from one character string - str.trim(); - root[name] = str; - } - else if (type == 8) - { - root[name] = rsbuffer[i] / 100.0; - } - else - { - root[name] = rsbuffer[i]; - } - } - } - } - else - { - root["status"] = "Modbus connection failed"; - } - root["requestaddress"] = address; - root["requestnum"] = nums; - } - else if (req[0] == "set" && req[2] != "" && req[3] != "") - { - int address = atoi(req[2].c_str()); - int value = atoi(req[3].c_str()); - char result = WriteModbus(address, value); - root["result"] = result; - root["address"] = address; - root["value"] = value; - } - else if (req[0] == "help") - { - for (int i = 0; i < reqmax; i++) - { - root[groups[i]] = 0; - } - } - root["operation"] = req[0]; - root["group"] = req[1]; - return root; -} - -void mqttreconnect() -{ - int numretries = 0; - while (!mqttclient.connected() && numretries < 3) - { - if (mqttclient.connect(chipid, mqttusername, mqttpassword)) - { - mqttclient.subscribe("ventilation/ventset"); - mqttclient.subscribe("ventilation/modeset"); - mqttclient.subscribe("ventilation/runset"); - mqttclient.subscribe("ventilation/tempset"); - mqttclient.subscribe("ventilation/programset"); - } - else - { - delay(1000); - } - numretries++; - } -} - -void mqttcallback(char *topic, byte *payload, unsigned int length) -{ - // Check if topic is equal to string - if (strcmp(topic, "ventilation/ventset") == 0) - { - if (length == 1 && payload[0] >= '0' && payload[0] <= '4') - { - int16_t speed = payload[0] - '0'; - WriteModbus(VENTSET, speed); - } - } - if (strcmp(topic, "ventilation/modeset") == 0) - { - if (length == 1 && payload[0] >= '0' && payload[0] <= '4') - { - int16_t mode = payload[0] - '0'; - WriteModbus(MODESET, mode); - } - } - if (strcmp(topic, "ventilation/runset") == 0) - { - if (length == 1 && payload[0] >= '0' && payload[0] <= '1') - { - int16_t run = payload[0] - '0'; - WriteModbus(RUNSET, run); - } - } - if (strcmp(topic, "ventilation/tempset") == 0) - { - if (length == 4 && payload[0] >= '0' && payload[0] <= '2') - { - String str; - for (unsigned int i = 0; i < length; i++) - { - str += (char)payload[i]; - } - WriteModbus(TEMPSET, str.toInt()); - } - } - if (strcmp(topic, "ventilation/programset") == 0) - { - if (length == 1 && payload[0] >= '0' && payload[0] <= '4') - { - int16_t program = payload[0] - '0'; - WriteModbus(PROGRAMSET, program); - } - } - lastMsg = -SENDINTERVAL; -} - -bool readRequest(WiFiClient &client) -{ - req[0] = ""; - req[1] = ""; - req[2] = ""; - req[3] = ""; - - int n = -1; - while (client.connected()) - { - if (client.available()) - { - char c = client.read(); - if (c == '\n') - { - return false; - } - else if (c == '/') - { - n++; - } - else if (c != ' ' && n >= 0 && n < 4) - { - req[n] += c; - } - else if (c == ' ' && n >= 0 && n < 4) - { - return true; - } - } - } - - return false; -} - -void writeResponse(WiFiClient &client, const JsonDocument &doc) -{ - client.println("HTTP/1.1 200 OK"); - client.println("Content-Type: application/json"); - client.println("Connection: close"); - // Fix: To adhere to RFC2616 section 14.13. Calculate length of data to client - String response = ""; - serializeJsonPretty(doc, response); - client.print("Content-Length: "); - client.println(response.length()); - client.println(); - client.print(response); -} - -void setup() -{ - char host[64]; - sprintf(chipid, "%08X", ESP.getChipId()); - sprintf(host, HOST, chipid); -#if USE_WIFI_LED - pinMode(WIFI_LED, OUTPUT); - digitalWrite(WIFI_LED, LOW); // Reverse meaning. LOW=LED ON -#endif - delay(500); - WiFi.mode(WIFI_STA); - WiFi.hostname(host); - ArduinoOTA.setHostname(host); - WiFi.mode(WIFI_STA); - WiFi.begin(ssid, password); - while (WiFi.waitForConnectResult() != WL_CONNECTED) - { - delay(5000); - // Give up and do a reboot - ESP.restart(); - } -#if USE_WIFI_LED - digitalWrite(WIFI_LED, HIGH); -#endif - ArduinoOTA.onStart([]() {}); - ArduinoOTA.onEnd([]() {}); - ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {}); - ArduinoOTA.onError([](ota_error_t error) {}); - ArduinoOTA.begin(); - server.begin(); - -#if SERIAL == SERIAL_SOFTWARE -#warning Compiling for software serial - SSerial.begin(19200, SERIAL_8E1); - node.begin(30, SSerial); -#elif SERIAL == SERIAL_HARDWARE -#warning Compiling for hardware serial - Serial.begin(19200, SERIAL_8E1); - node.begin(30, Serial); -#else -#error hardware og serial serial port? -#endif - - mqttclient.setServer(mqttserver, 1883); - mqttclient.setCallback(mqttcallback); -} - -void loop() -{ -#ifdef DEBUG_TELNET - // handle Telnet connection for debugging - handleTelnet(); -#endif - - ArduinoOTA.handle(); - WiFiClient client = server.available(); - if (client) - { - bool success = readRequest(client); - if (success) - { - StaticJsonDocument<1000> doc; - HandleRequest(doc); - writeResponse(client, doc); - } - client.stop(); - } - - if (!mqttclient.connected()) - { - mqttreconnect(); - } - - if (mqttclient.connected()) - { - mqttclient.loop(); - long now = millis(); - if (now - lastMsg > SENDINTERVAL) - { - reqtypes rr[] = {reqtemp, reqcontrol, reqtime, reqoutput, reqspeed, reqalarm, reqinputairtemp, reqprogram, requser, reqdisplay, reqinfo}; // put another register in this line to subscribe - for (unsigned int i = 0; i < (sizeof(rr) / sizeof(rr[0])); i++) - { - reqtypes r = rr[i]; - char result = ReadModbus(regaddresses[r], regsizes[r], rsbuffer, regtypes[r] & 1); - if (result == 0) - { - mqttclient.publish("ventilation/error/modbus/", "0"); // no error when connecting through modbus - for (int i = 0; i < regsizes[r]; i++) - { - char const *name = getName(r, i); - char numstr[10]; - if (name != NULL && strlen(name) > 0) - { - String mqname; - switch (r) - { - case reqcontrol: - mqname = "ventilation/control/"; // Subscribe to the "control" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqtime: - mqname = "ventilation/time/"; // Subscribe to the "output" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqoutput: - mqname = "ventilation/output/"; // Subscribe to the "output" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqdisplay: - mqname = "ventilation/display/"; // Subscribe to the "input display" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqspeed: - mqname = "ventilation/speed/"; // Subscribe to the "speed" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqalarm: - mqname = "ventilation/alarm/"; // Subscribe to the "alarm" register - - switch (i) - { - case 1: // Alarm.List_1_ID - case 4: // Alarm.List_2_ID - case 7: // Alarm.List_3_ID - if (rsbuffer[i] > 0) - { - // itoa((rsbuffer[i]), numstr, 10); - sprintf(numstr, "UNKNOWN"); // Preallocate unknown if no match if found - for (unsigned int p = 0; p < (sizeof(AlarmListNumber)); p++) - { - if (AlarmListNumber[p] == rsbuffer[i]) - { - // memset(numstr, 0, sizeof numstr); - // strcpy (numstr,AlarmListText[p].c_str()); - sprintf(numstr, AlarmListText[p].c_str()); - break; - } - } - } - else - { - sprintf(numstr, "None"); // No alarm, output None - } - break; - case 2: // Alarm.List_1_Date - case 5: // Alarm.List_2_Date - case 8: // Alarm.List_3_Date - if (rsbuffer[i] > 0) - { - sprintf(numstr, "%d", (rsbuffer[i] >> 9) + 1980); - sprintf(numstr + strlen(numstr), "-%02d", (rsbuffer[i] & 0x1E0) >> 5); - sprintf(numstr + strlen(numstr), "-%02d", (rsbuffer[i] & 0x1F)); - } - else - { - sprintf(numstr, "N/A"); // No alarm, output N/A - } - break; - case 3: // Alarm.List_1_Time - case 6: // Alarm.List_2_Time - case 9: // Alarm.List_3_Time - if (rsbuffer[i] > 0) - { - sprintf(numstr, "%02d", rsbuffer[i] >> 11); - sprintf(numstr + strlen(numstr), ":%02d", (rsbuffer[i] & 0x7E0) >> 5); - sprintf(numstr + strlen(numstr), ":%02d", (rsbuffer[i] & 0x11F) * 2); - } - else - { - sprintf(numstr, "N/A"); // No alarm, output N/A - } - - break; - default: // used for Status bit (case 0) - itoa((rsbuffer[i]), numstr, 10); - } - break; - case reqinputairtemp: - mqname = "ventilation/inputairtemp/"; // Subscribe to the "inputairtemp" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqprogram: - mqname = "ventilation/weekprogram/"; // Subscribe to the "week program" register - itoa((rsbuffer[i]), numstr, 10); - break; - case requser: - mqname = "ventilation/user/"; // Subscribe to the "user" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqinfo: - mqname = "ventilation/info/"; // Subscribe to the "info" register - itoa((rsbuffer[i]), numstr, 10); - break; - case reqtemp: - if (strncmp("RH", name, 2) == 0) - { - mqname = "ventilation/moist/"; // Subscribe to moisture-level - } - else - { - mqname = "ventilation/temp/"; // Subscribe to "temp" register - } - dtostrf((rsbuffer[i] / 100.0), 5, 2, numstr); - break; - } - mqname += (char *)name; - mqttclient.publish(mqname.c_str(), numstr); - } - } - } - else - { - mqttclient.publish("ventilation/error/modbus/", "1"); // error when connecting through modbus - } - } - - // Handle text fields - reqtypes rr2[] = {reqdisplay1, reqdisplay2}; // put another register in this line to subscribe - for (unsigned int i = 0; i < (sizeof(rr2) / sizeof(rr2[0])); i++) - { - reqtypes r = rr2[i]; - - char result = ReadModbus(regaddresses[r], regsizes[r], rsbuffer, regtypes[r] & 1); - if (result == 0) - { - String text = ""; - String mqname = "ventilation/text/"; - - for (int i = 0; i < regsizes[r]; i++) - { - char const *name = getName(r, i); - - if ((rsbuffer[i] & 0x00ff) == 0xDF) - { - text += (char)0x20; // replace degree sign with space - } - else - { - text += (char)(rsbuffer[i] & 0x00ff); - } - if ((rsbuffer[i] >> 8) == 0xDF) - { - text += (char)0x20; // replace degree sign with space - } - else - { - text += (char)(rsbuffer[i] >> 8); - } - mqname += (char *)name; - } - mqttclient.publish(mqname.c_str(), text.c_str()); - } - } - lastMsg = now; - } - } -} diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..e0c3e9f --- /dev/null +++ b/platformio.ini @@ -0,0 +1,42 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[platformio] +default_envs = nodemcuv2_ota +extra_configs = secrets.ini + +[env] +platform = espressif8266 +framework = arduino +lib_deps = + # RECOMMENDED + # Accept new functionality in a backwards compatible manner and patches + bblanchon/ArduinoJson @ ^6.19.4 + # RECOMMENDED + # Accept new functionality in a backwards compatible manner and patches + 4-20ma/ModbusMaster @ ^2.0.1 + # RECOMMENDED + # Accept new functionality in a backwards compatible manner and patches + knolleary/PubSubClient @ ^2.8 +lib_ignore = + # FIX for: WiFiUDP::stopAll(); 'stopAll' is not a member + # FIX: Block ardruino default wifi library + WiFi + +[env:nodemcuv2_usb] +board = nodemcuv2 +monitor_speed = 115200 +# Define upload port. Fix for error with HWID stated from platformIO car 6.1.0 (2022-07-06) +upload_port = COM6 + +[env:nodemcuv2_ota] +board = nodemcuv2 +upload_protocol = espota +; upload_port = 192.168.x.x # defined in secrets.ini diff --git a/src/configuration.h b/src/configuration.h new file mode 100644 index 0000000..42247c0 --- /dev/null +++ b/src/configuration.h @@ -0,0 +1,43 @@ +/* + * It should not be necessary to change anything else than in this file to suit your needs. + * If you need to change other things, consider adding to this file and submit a pull request :) + * Remember that all password, SSID´s and so on are CASE SENSITIVE ! + */ + +// Change config to verify that project has been configured +#ifndef CONFIGURED +#define CONFIGURED 0 // Change this to "1" when you completed your config and begin upload +#endif + +// WIFI settings +#ifndef WIFI_SSID // If already defined skip defining it again +#define WIFI_SSID "XX" // Put in your SSID +#define WIFI_PASSWORD "XX" // Put in your SSID Password +#endif + +// LED settings +#define WIFI_LED LED_BUILTIN // Blue led on NodeMCU +#define USE_WIFI_LED true // if 'true', the blue led in a NodeMCU will blink during connection, + // and glow solid once connected +// MQTT settings +#ifndef MQTT_SERVER // If already defined skip defining it again +#define MQTT_SERVER "XX" // Put in the IP addresses of your MQTT broker +#endif +#ifndef MQTT_USERNAME // If already defined skip defining it again +#define MQTT_USERNAME NULL // Username for the MQTT broker (NULL if no username is required) +#define MQTT_PASSWORD NULL // Password for the MQTT broker (NULL if no password is required) +#endif +#define MQTT_SEND_INTERVAL 600000 // normally set to 180000 milliseconds = 3 minutes. Define as you like + + +// Serial port +#define SERIAL_CHOICE SERIAL_HARDWARE // SERIAL_SOFTWARE or SERIAL_HARDWARE +#define SERIAL_SOFTWARE_RX D2 // only needed if SERIAL is SERIAL_SOFTWARE +#define SERIAL_SOFTWARE_TX D1 // only needed if SERIAL is SERIAL_SOFTWARE + +// Modbus address of the unit (possible to be changed via config) +#define MODBUS_SLAVE_ADDRESS 30 // Default is 30 + +#if CONFIGURED == 0 + #error "Default configuration used - won't upload to avoid loosing connection." +#endif diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d4e7cb7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,790 @@ +/** + Nilan Modbus firmware for D1 Mini (ESP8266) together with a TTL to RS485 Converter https://www.aliexpress.com/item/32836213346.html?spm=a2g0s.9042311.0.0.27424c4dqnr5i7 + + Written by Dan Gunvald + https://github.com/DanGunvald/NilanModbus + + Modified to use with Home Assistant by Anders Kvist, Jacob Scherrebeck and other great people :) + https://github.com/anderskvist/Nilan_Homeassistant + https://github.com/jascdk/Nilan_Homeassistant + + Read from a Nilan Air Vent System (Danish Brand) using a Wemos D1 + Mini (or other ESP8266-based board) and report the values to an MQTT + broker. Then use it for your home-automation system like Home Assistant. + + External dependencies. Install using the Arduino library manager: + + "Arduino JSON V6 by Benoît Blanchon https://github.com/bblanchon/ArduinoJson - IMPORTANT - Use latest V.6 !!! This code won´t compile with V.5 + "ModbusMaster by Doc Walker https://github.com/4-20ma/ModbusMaster + "PubSubClient" by Nick O'Leary https://github.com/knolleary/pubsubclient + + Project inspired by https://github.com/DanGunvald/NilanModbus +*/ + +#include +#include +#include +#include +#include +#include "configuration.h" +#define SERIAL_SOFTWARE 1 +#define SERIAL_HARDWARE 2 +#if SERIAL_CHOICE == SERIAL_SOFTWARE +// for some reason this library keeps beeing included when building +// #include +#endif +#define HOST "NilanGW-%s" // Change this to whatever you like. +#define MAX_REG_SIZE 26 +#define VENTSET 1003 +#define RUNSET 1001 +#define MODESET 1002 +#define TEMPSET 1004 +#define PROGRAMSET 500 +#define COMPILED __DATE__ " " __TIME__ + +#define DEBUG_SCAN_TIME // Turn on/off debugging of scan times +#ifdef DEBUG_SCAN_TIME +// Scan time variables +#define SCAN_COUNT_MAX 100000 +int scanTime = -1; // Used to measure scan times of program +int scanLast = -1; +int scanMax = -1; +int scanMin = 5000; // Set to a fake high number +double scanMovingAvr = 20; +int scanCount = 0; +#endif + +#if SERIAL_CHOICE == SERIAL_SOFTWARE +SoftwareSerial SSerial(SERIAL_SOFTWARE_RX, SERIAL_SOFTWARE_TX); // RX, TX +#endif + +const char *ssid = WIFI_SSID; +const char *password = WIFI_PASSWORD; +char chipID[12]; +const char *mqttServer = MQTT_SERVER; +const char *mqttUsername = MQTT_USERNAME; +const char *mqttPassword = MQTT_PASSWORD; +WiFiServer server(80); +WiFiClient wifiClient; +String IPaddress; +PubSubClient mqttClient(wifiClient); +long lastMsg = -MQTT_SEND_INTERVAL; +long modbusCooldown = 0; // Used to limit modbus read/write operations +int modbusCooldownHit = 0; // Used to limit modbus read/write operations +int16_t rsBuffer[MAX_REG_SIZE]; +ModbusMaster node; + +int16_t AlarmListNumber[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 70, 71, 90, 91, 92}; +String AlarmListText[] = {"NONE", "HARDWARE", "TIMEOUT", "FIRE", "PRESSURE", "DOOR", "DEFROST", "FROST", "FROST", "OVERTEMP", "OVERHEAT", "AIRFLOW", "THERMO", "BOILING", "SENSOR", "ROOM LOW", "SOFTWARE", "WATCHDOG", "CONFIG", "FILTER", "LEGIONEL", "POWER", "T AIR", "T WATER", "T HEAT", "MODEM", "INSTABUS", "T1SHORT", "T1OPEN", "T2SHORT", "T2OPEN", "T3SHORT", "T3OPEN", "T4SHORT", "T4OPEN", "T5SHORT", "T5OPEN", "T6SHORT", "T6OPEN", "T7SHORT", "T7OPEN", "T8SHORT", "T8OPEN", "T9SHORT", "T9OPEN", "T10SHORT", "T10OPEN", "T11SHORT", "T11OPEN", "T12SHORT", "T12OPEN", "T13SHORT", "T13OPEN", "T14SHORT", "T14OPEN", "T15SHORT", "T15OPEN", "T16SHORT", "T16OPEN", "ANODE", "EXCH INFO", "SLAVE IO", "OPT IO", "PRESET", "INSTABUS"}; + +String req[4]; // operation, group, address, value +enum ReqTypes +{ + reqtemp1 = 0, + reqtemp2, + reqtemp3, + reqalarm, + reqtime, + reqcontrol, + reqspeed, + reqairtemp, + reqairflow, + reqairheat, + reqprogram, + requser, + requser2, + reqinfo, + reqinputairtemp, + reqapp, + reqoutput, + reqdisplay1, + reqdisplay2, + reqdisplay, + reqmax +}; + +String groups[] = {"temp1", "temp2", "temp3", "alarm", "time", "control", "speed", "airtemp", "airflow", "airheat", "program", "user", "user2", "info", "inputairtemp", "app", "output", "display1", "display2", "display"}; + +// Start address to read from +int regAddresses[] = {203, 207, 221, 400, 300, 1000, 200, 1200, 1100, 0, 500, 600, 610, 100, 1200, 0, 100, 2002, 2007, 3000}; + +// How many values to read from based on start address +// byte regSizes[] = {23, 10, 6, 8, 2, 6, 2, 0, 1, 6, 6, 14, 7, 4, 26, 4, 4, 1}; +byte regSizes[] = {2, 2, 1, 10, 6, 8, 2, 6, 2, 0, 1, 6, 6, 14, 1, 4, 26, 4, 4, 1}; + +// 0=raw, 1=x, 2 = return 2 characters ASCII, +// 4=xx, 8= return float dived by 1000, +byte regTypes[] = {8, 8, 8, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 1, 4, 4, 8}; + +// Text translation of incoming data of the given address +char const *regNames[][MAX_REG_SIZE] = { + // temp + // {"T0_Controller", "T1_Intake", NULL, "T3_Exhaust", "T4_Outlet", NULL, NULL, "T7_Inlet", "T8_Outdoor", NULL, NULL, NULL, NULL, NULL, NULL, "T15_Room", NULL, NULL, NULL, NULL, NULL, "RH", NULL}, + {"T3_Exhaust", "T4_Outlet"}, + {"T7_Inlet", "T8_Outdoor", NULL, NULL, NULL, NULL, NULL, NULL, "T15_Room", NULL, NULL, NULL, NULL, NULL, "RH", NULL}, + {"RH"}, + // alarm + {"Status", "List_1_ID", "List_1_Date", "List_1_Time", "List_2_ID", "List_2_Date", "List_2_Time", "List_3_ID", "List_3_Date", "List_3_Time"}, + // time + {"Second", "Minute", "Hour", "Day", "Month", "Year"}, + // control + {"Type", "RunSet", "ModeSet", "VentSet", "TempSet", "ServiceMode", "ServicePct", "Preset"}, + // speed + {"ExhaustSpeed", "InletSpeed"}, + // airtemp + {"CoolSet", "TempMinSum", "TempMinWin", "TempMaxSum", "TempMaxWin", "TempSummer"}, + // airflow + {"AirExchMode", "CoolVent"}, + // airheat + {}, + // program + {"Program"}, + // program.user + {"UserFuncAct", "UserFuncSet", "UserTimeSet", "UserVentSet", "UserTempSet", "UserOffsSet"}, + // program.user2 requires the optional print board + {"User2FuncAct", "User2FuncSet", "User2TimeSet", "User2VentSet", "User2TempSet", "User2OffsSet"}, + // info + {"UserFunc", "AirFilter", "DoorOpen", "Smoke", "MotorThermo", "Frost_overht", "AirFlow", "P_Hi", "P_Lo", "Boil", "3WayPos", "DefrostHG", "Defrost", "UserFunc_2"}, + // inputairtemp + {"IsSummer", "TempInletSet", "TempControl", "TempRoom", "EffPct", "CapSet", "CapAct"}, + // app + {"Bus.Version", "VersionMajor", "VersionMinor", "VersionRelease"}, + // output + {"AirFlap", "SmokeFlap", "BypassOpen", "BypassClose", "AirCircPump", "AirHeatAllow", "AirHeat_1", "AirHeat_2", "AirHeat_3", "Compressor", "Compressor_2", "4WayCool", "HotGasHeat", "HotGasCool", "CondOpen", "CondClose", "WaterHeat", "3WayValve", "CenCircPump", "CenHeat_1", "CenHeat_2", "CenHeat_3", "CenHeatExt", "UserFunc", "UserFunc_2", "Defrosting"}, + // display1 + {"Text_1_2", "Text_3_4", "Text_5_6", "Text_7_8"}, + // display2 + {"Text_9_10", "Text_11_12", "Text_13_14", "Text_15_16"}, + // air bypass + {"AirBypass/IsOpen"}}; + +char const *getName(ReqTypes type, int address) +{ + if (address >= 0 && address <= regSizes[type]) + { + return regNames[type][address]; + } + return NULL; +} + +void modbusCool(int coolDownTimeMS) +{ + // Fix for breaking out of modbus error loop + if ((long)millis() < (long)modbusCooldown) + { + if (modbusCooldownHit > 50) + { + ESP.reset(); + } + modbusCooldownHit++; + while ((long)millis() < (long)modbusCooldown) + { + delay(20); + } + } + else if (modbusCooldownHit > 0) + { + modbusCooldownHit = 0; + } + modbusCooldown = millis() + coolDownTimeMS; +} + +char WriteModbus(uint16_t addr, int16_t val) +{ + modbusCool(200); + node.setTransmitBuffer(0, val); + char result = 0; + result = node.writeMultipleRegisters(addr, 1); + return result; +} + +char ReadModbus(uint16_t addr, uint8_t sizer, int16_t *vals, int type) +{ + modbusCool(200); + char result = 0; + // Make sure type is either 0 or 1 + switch (type & 1) + { + case 0: + result = node.readInputRegisters(addr, sizer); + break; + case 1: + result = node.readHoldingRegisters(addr, sizer); + break; + } + if (result == node.ku8MBSuccess) + { + for (int j = 0; j < sizer; j++) + { + vals[j] = node.getResponseBuffer(j); + } + return result; + } + return result; +} + +JsonObject HandleRequest(JsonDocument &doc) +{ + JsonObject root = doc.to(); + ReqTypes r = reqmax; + if (req[1] != "") + { + for (int i = 0; i < reqmax; i++) + { + if (groups[i] == req[1]) + { + r = (ReqTypes)i; + } + } + } + char type = regTypes[r]; + if (req[0] == "read") + { + int address = 0; + int nums = 0; + char result = -1; + address = regAddresses[r]; + nums = regSizes[r]; + + result = ReadModbus(address, nums, rsBuffer, type); + if (result == 0) + { + root["status"] = "Modbus connection OK"; + for (int i = 0; i < nums; i++) + { + char const *name = getName(r, i); + if (name != NULL && strlen(name) > 0) + { + if ((type == 2 && i > 0) || type == 4) + { + String str = ""; + str += (char)(rsBuffer[i] & 0x00ff); + str = (char)(rsBuffer[i] >> 8) + str; + // Remove leading space from one character string + str.trim(); + root[name] = str; + } + else if (type == 8) + { + root[name] = rsBuffer[i] / 100.0; + } + else + { + root[name] = rsBuffer[i]; + } + } + } + } + else + { + root["status"] = "Modbus connection failed"; + } + root["requestAddress"] = address; + root["requestNumber"] = nums; + } + else if (req[0] == "set" && req[2] != "" && req[3] != "") + { + int address = atoi(req[2].c_str()); + int value = atoi(req[3].c_str()); + char result = WriteModbus(address, value); + root["result"] = result; + root["address"] = address; + root["value"] = value; + } + else if (req[0] == "get" && req[1] >= "0" && req[2] > "0") + { + int address = atoi(req[1].c_str()); + int nums = atoi(req[2].c_str()); + int type = atoi(req[3].c_str()); + char result = ReadModbus(address, nums, rsBuffer, type); + if (result == 0) + // if (true) + { + root["status"] = "Modbus connection OK"; + for (int i = 0; i < nums; i++) + { + root[String("address" + String(address + i))] = rsBuffer[i]; + } + } + else + { + root["status"] = "Modbus connection failed"; + } + root["result"] = result; + root["requestAddress"] = address; + root["requestNumber"] = nums; + switch (type) + { + case 0: + root["type"] = "Input register"; + break; + case 1: + root["type"] = "Holding register"; + break; + default: + root["type"] = "Should be 0 or 1 for input/holding register"; + } + } + else if (req[0] == "help" || req[0] == "") + { + for (int i = 0; i < reqmax; i++) + { + root[groups[i]] = "http://../read/" + groups[i]; + } + } + root["operation"] = req[0]; + root["group"] = req[1]; + return root; +} + +void mqttReconnect() +{ + int numberRetries = 0; + while (!mqttClient.connected() && numberRetries < 3) + { + if (mqttClient.connect(chipID, mqttUsername, mqttPassword, "ventilation/alive", 1, true, "0")) + { + mqttClient.publish("ventilation/alive", "1", true); + mqttClient.subscribe("ventilation/cmd/+"); + return; + } + else + { + delay(1000); + } + numberRetries++; + } + if (numberRetries >= 3) + { + delay(5000); + // Give up and do a reboot + ESP.restart(); + } +} + +void mqttCallback(char *topic, byte *payload, unsigned int length) +{ + String inputString; + for (unsigned int i = 0; i < length; i++) + { + inputString += (char)payload[i]; + } + // Check if topic is equal to string + if (strcmp(topic, "ventilation/cmd/ventset") == 0) + { + if (length == 1 && payload[0] >= '0' && payload[0] <= '4') + { + int16_t speed = payload[0] - '0'; + WriteModbus(VENTSET, speed); + mqttClient.publish("ventilation/cmd/ventset", "", true); + } + } + else if (strcmp(topic, "ventilation/cmd/modeset") == 0) + { + if (length == 1 && payload[0] >= '0' && payload[0] <= '4') + { + int16_t mode = payload[0] - '0'; + WriteModbus(MODESET, mode); + mqttClient.publish("ventilation/cmd/modeset", "", true); + } + } + else if (strcmp(topic, "ventilation/cmd/runset") == 0) + { + if (length == 1 && payload[0] >= '0' && payload[0] <= '1') + { + int16_t run = payload[0] - '0'; + WriteModbus(RUNSET, run); + mqttClient.publish("ventilation/cmd/runset", "", true); + } + } + else if (strcmp(topic, "ventilation/cmd/tempset") == 0) + { + if (length == 4 && payload[0] >= '0' && payload[0] <= '2') + { + WriteModbus(TEMPSET, inputString.toInt()); + mqttClient.publish("ventilation/cmd/tempset", "", true); + } + } + else if (strcmp(topic, "ventilation/cmd/programset") == 0) + { + if (length == 1 && payload[0] >= '0' && payload[0] <= '4') + { + int16_t program = payload[0] - '0'; + WriteModbus(PROGRAMSET, program); + mqttClient.publish("ventilation/cmd/programset", "", true); + } + } + else if (strcmp(topic, "ventilation/cmd/update") == 0) + { + // Enter mode in 60 seconds to prioritize OTA + if (payload[0] == '1') + { + mqttClient.publish("ventilation/cmd/update", "2"); + for (unsigned int i = 0; i < 300; i++) + { + ArduinoOTA.handle(); + delay(200); + } + if (!mqttClient.connected()) + { + mqttReconnect(); + } + } + mqttClient.publish("ventilation/cmd/update", "0"); + } + else if (strcmp(topic, "ventilation/cmd/reboot") == 0) + { + if (payload[0] == '1') + { + mqttClient.publish("ventilation/cmd/reboot", "0"); + ESP.restart(); + } + } + else if (strcmp(topic, "ventilation/cmd/version") == 0) + { + if (inputString != String(COMPILED)) + { + mqttClient.publish(topic, String(COMPILED).c_str()); + } + } + else + { + mqttClient.publish("ventilation/error/topic", topic); + } + lastMsg = -MQTT_SEND_INTERVAL; +} + +bool readRequest(WiFiClient &client) +{ + req[0] = ""; + req[1] = ""; + req[2] = ""; + req[3] = ""; + + int n = -1; + while (client.connected()) + { + if (client.available()) + { + char c = client.read(); + if (c == '\n') + { + return false; + } + else if (c == '/') + { + n++; + } + else if (c != ' ' && n >= 0 && n < 4) + { + req[n] += c; + } + else if (c == ' ' && n >= 0 && n < 4) + { + return true; + } + } + } + + return false; +} + +void writeResponse(WiFiClient &client, const JsonDocument &doc) +{ + client.println("HTTP/1.1 200 OK"); + client.println("Content-Type: application/json"); + client.println("Connection: close"); + // Fix: To adhere to RFC2616 section 14.13. Calculate length of data to client + String response = ""; + serializeJsonPretty(doc, response); + client.print("Content-Length: "); + client.println(response.length()); + client.println(); + client.print(response); +} + +void setup() +{ + char host[64]; + sprintf(chipID, "%08X", ESP.getChipId()); + sprintf(host, HOST, chipID); +#if USE_WIFI_LED + pinMode(WIFI_LED, OUTPUT); + digitalWrite(WIFI_LED, LOW); // Reverse meaning. LOW=LED ON +#endif + delay(500); + WiFi.mode(WIFI_STA); + WiFi.hostname(host); + WiFi.begin(ssid, password); + while (WiFi.waitForConnectResult() != WL_CONNECTED) + { + delay(5000); + // Give up and do a reboot + ESP.restart(); + } +#if USE_WIFI_LED + digitalWrite(WIFI_LED, HIGH); +#endif + ArduinoOTA.setHostname(host); + ArduinoOTA.begin(); + server.begin(); + +#if SERIAL_CHOICE == SERIAL_SOFTWARE +#warning Compiling for software serial + SSerial.begin(19200, SWSERIAL_8E1); + node.begin(MODBUS_SLAVE_ADDRESS, SSerial); +#elif SERIAL_CHOICE == SERIAL_HARDWARE +#warning Compiling for hardware serial + Serial.begin(19200, SERIAL_8E1); + node.begin(MODBUS_SLAVE_ADDRESS, Serial); +#else +#error hardware og serial serial port? +#endif + + mqttClient.setServer(mqttServer, 1883); + mqttClient.setCallback(mqttCallback); + mqttReconnect(); + mqttClient.publish("ventilation/gateway/boot", String(millis()).c_str()); + IPaddress = WiFi.localIP().toString(); + mqttClient.publish("ventilation/gateway/ip", IPaddress.c_str()); +} + +#ifdef DEBUG_SCAN_TIME +// Scan time is the time then looping part of a program runs in miliseconds. +// Rule of thumb is to allow max 20ms to rule the program as runnning "live" and not async +// Live running programs are relevant when expecting non buffered IO operations with the real world +void scanTimer() +{ + if (scanCount > SCAN_COUNT_MAX) + { + return; + } + if (scanLast == -1) + { + scanLast = millis(); + return; + } + scanTime = millis() - scanLast; + if (scanTime > scanMax) + scanMax = scanTime; + if (scanTime < scanMin) + scanMin = scanTime; + scanCount++; + scanMovingAvr = scanTime * (0.3 / (1 + scanCount)) + scanMovingAvr * (1 - (0.3 / (1 + scanCount))); + if (scanCount > SCAN_COUNT_MAX) + { + mqttClient.publish("ventilation/debug/scanMin", String(scanMin).c_str()); + mqttClient.publish("ventilation/debug/scanMax", String(scanMax).c_str()); + mqttClient.publish("ventilation/debug/scanMovingAvr", String(floor(scanMovingAvr * 100) / 100).c_str()); + } + scanLast = millis(); +} +#endif + +void loop() +{ + ArduinoOTA.handle(); + WiFiClient client = server.available(); + if (client) + { + bool success = readRequest(client); + if (success) + { + StaticJsonDocument<1000> doc; + HandleRequest(doc); + writeResponse(client, doc); + } + client.stop(); + } + + if (!mqttClient.connected()) + { + mqttReconnect(); + } + + if (mqttClient.connected()) + { + mqttClient.loop(); + long now = millis(); + if (now - lastMsg > MQTT_SEND_INTERVAL) + { + // ReqTypes rr[] = {reqtemp, reqcontrol, reqtime, reqoutput, reqspeed, reqalarm, reqinputairtemp, reqprogram, requser, reqdisplay, reqinfo}; // put another register in this line to subscribe + ReqTypes rr[] = {reqtemp1, reqtemp2, reqtemp3, reqcontrol, reqalarm, reqinputairtemp, reqprogram, reqdisplay, requser}; // put another register in this line to subscribe + for (unsigned int i = 0; i < (sizeof(rr) / sizeof(rr[0])); i++) + { + ReqTypes r = rr[i]; + char result = ReadModbus(regAddresses[r], regSizes[r], rsBuffer, regTypes[r]); + if (result == 0) + { + mqttClient.publish("ventilation/error/modbus", "0"); // no error when connecting through modbus + for (int i = 0; i < regSizes[r]; i++) + { + char const *name = getName(r, i); + char numberString[10]; + if (name != NULL && strlen(name) > 0) + { + String mqttTopic; + switch (r) + { + case reqcontrol: + mqttTopic = "ventilation/control/"; // Subscribe to the "control" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqtime: + mqttTopic = "ventilation/time/"; // Subscribe to the "output" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqoutput: + mqttTopic = "ventilation/output/"; // Subscribe to the "output" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqdisplay: + mqttTopic = "ventilation/display/"; // Subscribe to the "input display" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqspeed: + mqttTopic = "ventilation/speed/"; // Subscribe to the "speed" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqalarm: + mqttTopic = "ventilation/alarm/"; // Subscribe to the "alarm" register + + switch (i) + { + case 1: // Alarm.List_1_ID + case 4: // Alarm.List_2_ID + case 7: // Alarm.List_3_ID + if (rsBuffer[i] > 0) + { + // itoa((rsBuffer[i]), numberString, 10); + sprintf(numberString, "UNKNOWN"); // Preallocate unknown if no match if found + for (unsigned int p = 0; p < (sizeof(AlarmListNumber)); p++) + { + if (AlarmListNumber[p] == rsBuffer[i]) + { + // memset(numberString, 0, sizeof numberString); + // strcpy (numberString,AlarmListText[p].c_str()); + sprintf(numberString, AlarmListText[p].c_str()); + break; + } + } + } + else + { + sprintf(numberString, "None"); // No alarm, output None + } + break; + case 2: // Alarm.List_1_Date + case 5: // Alarm.List_2_Date + case 8: // Alarm.List_3_Date + if (rsBuffer[i] > 0) + { + sprintf(numberString, "%d", (rsBuffer[i] >> 9) + 1980); + sprintf(numberString + strlen(numberString), "-%02d", (rsBuffer[i] & 0x1E0) >> 5); + sprintf(numberString + strlen(numberString), "-%02d", (rsBuffer[i] & 0x1F)); + } + else + { + sprintf(numberString, "N/A"); // No alarm, output N/A + } + break; + case 3: // Alarm.List_1_Time + case 6: // Alarm.List_2_Time + case 9: // Alarm.List_3_Time + if (rsBuffer[i] > 0) + { + sprintf(numberString, "%02d", rsBuffer[i] >> 11); + sprintf(numberString + strlen(numberString), ":%02d", (rsBuffer[i] & 0x7E0) >> 5); + sprintf(numberString + strlen(numberString), ":%02d", (rsBuffer[i] & 0x11F) * 2); + } + else + { + sprintf(numberString, "N/A"); // No alarm, output N/A + } + + break; + default: // used for Status bit (case 0) + itoa((rsBuffer[i]), numberString, 10); + } + break; + case reqinputairtemp: + mqttTopic = "ventilation/inputairtemp/"; // Subscribe to the "inputairtemp" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqprogram: + mqttTopic = "ventilation/weekprogram/"; // Subscribe to the "week program" register + itoa((rsBuffer[i]), numberString, 10); + break; + case requser: + mqttTopic = "ventilation/user/"; // Subscribe to the "user" register + itoa((rsBuffer[i]), numberString, 10); + break; + case requser2: + mqttTopic = "ventilation/user/"; // Subscribe to the "user2" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqinfo: + mqttTopic = "ventilation/info/"; // Subscribe to the "info" register + itoa((rsBuffer[i]), numberString, 10); + break; + case reqtemp1: + if (strncmp("RH", name, 2) == 0) + { + mqttTopic = "ventilation/moist/"; // Subscribe to moisture-level + } + else + { + mqttTopic = "ventilation/temp/"; // Subscribe to "temp" register + } + dtostrf((rsBuffer[i] / 100.0), 5, 2, numberString); + break; + case reqtemp2: + if (strncmp("RH", name, 2) == 0) + { + mqttTopic = "ventilation/moist/"; // Subscribe to moisture-level + } + else + { + mqttTopic = "ventilation/temp/"; // Subscribe to "temp" register + } + dtostrf((rsBuffer[i] / 100.0), 5, 2, numberString); + break; + case reqtemp3: + if (strncmp("RH", name, 2) == 0) + { + mqttTopic = "ventilation/moist/"; // Subscribe to moisture-level + } + else + { + mqttTopic = "ventilation/temp/"; // Subscribe to "temp" register + } + dtostrf((rsBuffer[i] / 100.0), 5, 2, numberString); + break; + default: + // If not all enumerations possibilities are handled then message are added to the unmapped topic + mqttTopic = "ventilation/unmapped/"; + break; + } + mqttTopic += (char *)name; + mqttClient.publish(mqttTopic.c_str(), numberString); + } + } + } + else + { + mqttClient.publish("ventilation/error/modbus", "1"); // error when connecting through modbus + } + } + lastMsg = now; + if (now > (long)1288490187) + { + // Fix to make sure the command millis() dont overflow. This happens after 50 days and would mess up some logic above + // Reboot if ESP has been running for approximately 30 days. + ESP.restart(); + } + } + } +#ifdef DEBUG_SCAN_TIME + scanTimer(); +#endif +} \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html