diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..f2d86c5 --- /dev/null +++ b/.clang-format @@ -0,0 +1,137 @@ +Language: Cpp +AccessModifierOffset: -1 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: DontAlign +AlignOperands: true +AlignTrailingComments: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: MultiLine +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeInheritanceComma: false +BreakInheritanceList: BeforeColon +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakConstructorInitializers: BeforeColon +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: true +ColumnLimit: 120 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^' + Priority: 2 + - Regex: '^<.*\.h>' + Priority: 1 + - Regex: '^<.*' + Priority: 2 + - Regex: '.*' + Priority: 3 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IndentCaseLabels: true +IndentPPDirectives: None +IndentWidth: 2 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 2000 +PointerAlignment: Right +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google + - Language: TextProto + Delimiters: + - pb + - PB + - proto + - PROTO + EnclosingFunctions: + - EqualsProto + - EquivToProto + - PARSE_PARTIAL_TEXT_PROTO + - PARSE_TEST_PROTO + - PARSE_TEXT_PROTO + - ParseTextOrDie + - ParseTextProtoOrDie + CanonicalDelimiter: '' + BasedOnStyle: google +ReflowComments: true +SortIncludes: false +SortUsingDeclarations: false +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInContainerLiterals: false +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +TabWidth: 2 +UseTab: Never diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..c9b77b5 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,160 @@ +--- +Checks: >- + *, + -abseil-*, + -altera-*, + -android-*, + -boost-*, + -bugprone-narrowing-conversions, + -bugprone-signed-char-misuse, + -cert-dcl50-cpp, + -cert-err58-cpp, + -cert-oop57-cpp, + -cert-str34-c, + -clang-analyzer-optin.cplusplus.UninitializedObject, + -clang-analyzer-osx.*, + -clang-diagnostic-delete-abstract-non-virtual-dtor, + -clang-diagnostic-delete-non-abstract-non-virtual-dtor, + -clang-diagnostic-shadow-field, + -clang-diagnostic-unused-const-variable, + -clang-diagnostic-unused-parameter, + -concurrency-*, + -cppcoreguidelines-avoid-c-arrays, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-init-variables, + -cppcoreguidelines-macro-usage, + -cppcoreguidelines-narrowing-conversions, + -cppcoreguidelines-non-private-member-variables-in-classes, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-const-cast, + -cppcoreguidelines-pro-type-cstyle-cast, + -cppcoreguidelines-pro-type-member-init, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-static-cast-downcast, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-pro-type-vararg, + -cppcoreguidelines-special-member-functions, + -fuchsia-multiple-inheritance, + -fuchsia-overloaded-operator, + -fuchsia-statically-constructed-objects, + -fuchsia-default-arguments-declarations, + -fuchsia-default-arguments-calls, + -google-build-using-namespace, + -google-explicit-constructor, + -google-readability-braces-around-statements, + -google-readability-casting, + -google-readability-namespace-comments, + -google-readability-todo, + -google-runtime-references, + -hicpp-*, + -llvm-else-after-return, + -llvm-header-guard, + -llvm-include-order, + -llvm-qualified-auto, + -llvmlibc-*, + -misc-non-private-member-variables-in-classes, + -misc-no-recursion, + -misc-unused-parameters, + -modernize-avoid-c-arrays, + -modernize-avoid-bind, + -modernize-concat-nested-namespaces, + -modernize-return-braced-init-list, + -modernize-use-auto, + -modernize-use-default-member-init, + -modernize-use-equals-default, + -modernize-use-trailing-return-type, + -modernize-use-nodiscard, + -mpi-*, + -objc-*, + -readability-convert-member-functions-to-static, + -readability-else-after-return, + -readability-function-cognitive-complexity, + -readability-implicit-bool-conversion, + -readability-isolate-declaration, + -readability-magic-numbers, + -readability-make-member-function-const, + -readability-redundant-string-init, + -readability-uppercase-literal-suffix, + -readability-use-anyofallof, +WarningsAsErrors: '*' +AnalyzeTemporaryDtors: false +FormatStyle: google +CheckOptions: + - key: google-readability-braces-around-statements.ShortStatementLines + value: '1' + - key: google-readability-function-size.StatementThreshold + value: '800' + - key: google-runtime-int.TypeSuffix + value: '_t' + - key: llvm-namespace-comment.ShortNamespaceLines + value: '10' + - key: llvm-namespace-comment.SpacesBeforeComments + value: '2' + - key: modernize-loop-convert.MaxCopySize + value: '16' + - key: modernize-loop-convert.MinConfidence + value: reasonable + - key: modernize-loop-convert.NamingStyle + value: CamelCase + - key: modernize-pass-by-value.IncludeStyle + value: llvm + - key: modernize-replace-auto-ptr.IncludeStyle + value: llvm + - key: modernize-use-nullptr.NullMacros + value: 'NULL' + - key: modernize-make-unique.MakeSmartPtrFunction + value: 'make_unique' + - key: modernize-make-unique.MakeSmartPtrFunctionHeader + value: 'esphome/core/helpers.h' + - key: readability-braces-around-statements.ShortStatementLines + value: 2 + - key: readability-identifier-naming.LocalVariableCase + value: 'lower_case' + - key: readability-identifier-naming.ClassCase + value: 'CamelCase' + - key: readability-identifier-naming.StructCase + value: 'CamelCase' + - key: readability-identifier-naming.EnumCase + value: 'CamelCase' + - key: readability-identifier-naming.EnumConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.StaticConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.StaticVariableCase + value: 'lower_case' + - key: readability-identifier-naming.GlobalConstantCase + value: 'UPPER_CASE' + - key: readability-identifier-naming.ParameterCase + value: 'lower_case' + - key: readability-identifier-naming.PrivateMemberCase + value: 'lower_case' + - key: readability-identifier-naming.PrivateMemberSuffix + value: '_' + - key: readability-identifier-naming.PrivateMethodCase + value: 'lower_case' + - key: readability-identifier-naming.PrivateMethodSuffix + value: '_' + - key: readability-identifier-naming.ClassMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ClassMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMemberCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMemberSuffix + value: '_' + - key: readability-identifier-naming.FunctionCase + value: 'lower_case' + - key: readability-identifier-naming.ClassMethodCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMethodCase + value: 'lower_case' + - key: readability-identifier-naming.ProtectedMethodSuffix + value: '_' + - key: readability-identifier-naming.VirtualMethodCase + value: 'lower_case' + - key: readability-identifier-naming.VirtualMethodSuffix + value: '' + - key: readability-qualified-auto.AddConstToQualified + value: 0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d6e68a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml + +**/__pycache__/** diff --git a/README.md b/README.md index 9068c83..95ffe85 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ -# esphome-truma_inetbox -ESPHome component to remote control Truma CP Plus Heater +# ESPHome truma_inetbox component + +ESPHome component to remote control Truma CP Plus Heater by simulating a Truma iNet box. + +See [1](https://github.com/danielfett/inetbox.py) and [2](https://github.com/mc0110/inetbox2mqtt) for great documentation about how to connect an CP Plus to an ESP32. + +## Acknowledgements + +This project is based on the work of the [WomoLIN project](https://github.com/muccc/WomoLIN) and [mc0110 inetbox.py](https://github.com/danielfett/inetbox.py), especially the initial protocol decoding and the inet box log files. + +## Example configuation + +This example is just for connecting ESPHome to the CP Plus. See [truma.yaml](/truma.yaml) for an example config with all possible things configured. + +```yaml +esphome: + name: "esphome-truma" + +external_components: + - source: github://Fabian-Schmidt/esphome-truma_inetbox + +esp32: + board: mhetesp32devkit + +uart: + - id: lin_uart_bus + baud_rate: 9600 + stop_bits: 2 + +truma_inetbox: + uart_id: lin_uart_bus + +binary_sensor: + - platform: truma_inetbox + name: "CP Plus alive" + type: CP_PLUS_CONNECTED + +sensor: + - platform: truma_inetbox + name: "Current Room Temperature" + type: CURRENT_ROOM_TEMPERATURE + - platform: truma_inetbox + name: "Current Water Temperature" + type: CURRENT_WATER_TEMPERATURE + - platform: truma_inetbox + name: "Target Room Temperature" + type: TARGET_ROOM_TEMPERATURE + - platform: truma_inetbox + name: "Target Water Temperature" + type: TARGET_WATER_TEMPERATURE +``` + +## ESPHome components + +This project contains the following ESPHome components: + +- `uart` will overwrite the default uart component to expose internal fields. +- `truma_inetbox` has the following settings: + - `cs_pin` (optional) if you connect the pin of your lin driver chip. + - `fault_pin` (optional) if you connect the pin of your lin driver chip. + - `on_heater_message` (optional) [ESPHome Trigger](https://esphome.io/guides/automations.html) when a message from CP Plus is recieved. + +### Binary sensor + +Binary sensors are read-only. + +```yaml +binary_sensor: + - platform: truma_inetbox + name: "CP Plus alive" + type: CP_PLUS_CONNECTED +``` + +The following `type` values are available: + +- `CP_PLUS_CONNECTED` +- `HEATER_ROOM` +- `HEATER_WATER` +- `HEATER_GAS` +- `HEATER_MIX_1` +- `HEATER_MIX_2` +- `HEATER_ELECTRICITY` +- `TIMER_ACTIVE` +- `TIMER_ROOM` +- `TIMER_WATER` + +### Climate + +Climate components support read and write. + +```yaml +climate: + - platform: truma_inetbox + name: "Truma Room" + type: ROOM + - platform: truma_inetbox + name: "Truma Water" + type: WATER +``` + +The following `type` values are available: + +- `ROOM` +- `WATER` + +### Number + +Number components support read and write. + +```yaml +number: + - platform: truma_inetbox + name: "Target Room Temperature" + type: TARGET_ROOM_TEMPERATURE +``` + +The following `type` values are available: + +- `TARGET_ROOM_TEMPERATURE` +- `TARGET_WATER_TEMPERATURE` +- `ELECTRIC_POWER_LEVEL` + +### Sensor + +Sensors are read-only. + +```yaml +sensor: + - platform: truma_inetbox + name: "Current Room Temperature" + type: CURRENT_ROOM_TEMPERATURE +``` + +The following `type` values are available: + +- `CURRENT_ROOM_TEMPERATURE` +- `CURRENT_WATER_TEMPERATURE` +- `TARGET_ROOM_TEMPERATURE` +- `TARGET_WATER_TEMPERATURE` +- `HEATING_MODE` +- `ELECTRIC_POWER_LEVEL` +- `ENERGY_MIX` +- `OPERATING_STATUS` + +### Actions + +The following [ESP Home actions](https://esphome.io/guides/automations.html#actions) are available: + +- `truma_inetbox.heater.set_target_room_temperature` + - `temperature` - Temperature between 5C and 30C. Below 5C will disable the Heater. + - `heating_mode` - Optional set heating mode: `"OFF"`, `ECO`, `HIGH`, `BOOST`. +- `truma_inetbox.heater.set_target_water_temperature` + - `temperature` - Set water temp as number: `0`, `40`, `60`, `80`. +- `truma_inetbox.heater.set_target_water_temperature_enum` + - `temperature` - Set water temp as text: `"OFF"`, `ECO`, `HIGH`, `BOOST`. +- `truma_inetbox.heater.set_electric_power_level` + - `watt` - Set electricity level to `0`, `900`, `1800`. +- `truma_inetbox.heater.set_energy_mix` + - `energy_mix` - Set energy mix to: `GAS`, `MIX`, `ELECTRICITY`. + - `watt` - Optional: Set electricity level to `0`, `900`, `1800` +- `truma_inetbox.timer.disable` - Disable the timer configuration. +- `truma_inetbox.timer.activate` - Set a new timer configuration. + - `start` - Start time. + - `stop` - Stop time. + - `room_temperature` - Temperature between 5C and 30C. + - `heating_mode` - Optional: Set heating mode: `"OFF"`, `ECO`, `HIGH`, `BOOST`. + - `water_temperature` - Optional: Set water temp as number: `0`, `40`, `60`, `80`. + - `energy_mix` - Optional: Set energy mix to: `GAS`, `MIX`, `ELECTRICITY`. + - `watt` - Optional: Set electricity level to `0`, `900`, `1800`. +- `truma_inetbox.clock.set` - Update CP Plus from ESP Home. You *must* have another [clock source](https://esphome.io/#time-components) configured like Home Assistant Time, GPS or DS1307 RTC. + +## TODO + +- [ ] This file +- [ ] ESP32 IDF support +- [ ] RP2040 support +- [ ] Testing of Combi 4E / Combi 6E and Alde devices (I only have access to an Combi 4) +- [ ] More Testing diff --git a/components/truma_inetbox/LinBusListener.cpp b/components/truma_inetbox/LinBusListener.cpp new file mode 100644 index 0000000..fc2d448 --- /dev/null +++ b/components/truma_inetbox/LinBusListener.cpp @@ -0,0 +1,340 @@ +#include "LinBusListener.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "helpers.h" + +#ifdef USE_ESP32 +#include "driver/uart.h" +#include "soc/uart_struct.h" +#include "soc/uart_reg.h" +#endif + +#ifdef USE_ESP8266 +#include "esphome/components/uart/uart_component_esp8266.h" +#endif +#ifdef USE_ESP32_FRAMEWORK_ARDUINO +#include "esphome/components/uart/truma_uart_component_esp32_arduino.h" +#include "esphome/components/uart/uart_component_esp32_arduino.h" +#endif +#ifdef USE_ESP32_FRAMEWORK_ESP_IDF +#include "esphome/components/uart/truma_uart_component_esp_idf.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#endif +#ifdef USE_RP2040 +#include "esphome/components/uart/truma_uart_component_rp2040.h" +#include "esphome/components/uart/uart_component_rp2040.h" +#endif + +#ifdef USE_ESP32_FRAMEWORK_ARDUINO +// For method `xTaskCreateUniversal` +#include +#endif + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.LinBusListener"; + +#define LIN_BREAK 0x00 +#define LIN_SYNC 0x55 +#define DIAGNOSTIC_FRAME_MASTER 0x3c +#define DIAGNOSTIC_FRAME_SLAVE 0x3d + +void LinBusListener::dump_config() { + ESP_LOGCONFIG(TAG, "TODO"); + + this->check_uart_settings(9600, 2, esphome::uart::UART_CONFIG_PARITY_NONE, 8); +} + +void LinBusListener::setup() { + ESP_LOGCONFIG(TAG, "Setting up LIN BUS..."); + this->time_per_baud_ = (1000.0f * 1000.0f / this->parent_->get_baud_rate()); + this->time_per_lin_break_ = this->time_per_baud_ * this->lin_break_length * 1.1; + this->time_per_pid_ = this->time_per_baud_ * this->frame_length_ * 1.1; + this->time_per_first_byte_ = this->time_per_baud_ * this->frame_length_ * 3.0; + this->time_per_byte_ = this->time_per_baud_ * this->frame_length_ * 1.1; + +#ifdef USE_ESP32_FRAMEWORK_ARDUINO + auto uartComp = static_cast(this->parent_); + auto uart_num = uartComp->get_hw_serial_number(); + + // Tweak the fifo settings so data is available as soon as the first byte is recieved. + // If not it will wait either until fifo is filled or a certain time has passed. + uart_intr_config_t uart_intr; + uart_intr.intr_enable_mask = + UART_RXFIFO_FULL_INT_ENA_M | UART_RXFIFO_TOUT_INT_ENA_M; // only these IRQs - no BREAK, PARITY or OVERFLOW + // UART_RXFIFO_FULL_INT_ENA_M | UART_RXFIFO_TOUT_INT_ENA_M | UART_FRM_ERR_INT_ENA_M | + // UART_RXFIFO_OVF_INT_ENA_M | UART_BRK_DET_INT_ENA_M | UART_PARITY_ERR_INT_ENA_M; + uart_intr.rxfifo_full_thresh = + 1; // UART_FULL_THRESH_DEFAULT, //120 default!! aghh! need receive 120 chars before we see them + uart_intr.rx_timeout_thresh = + 10; // UART_TOUT_THRESH_DEFAULT, //10 works well for my short messages I need send/receive + uart_intr.txfifo_empty_intr_thresh = 10; // UART_EMPTY_THRESH_DEFAULT + uart_intr_config(uart_num, &uart_intr); +#elif USE_ESP32_FRAMEWORK_ESP_IDF + + // uartSetFastReading + auto uartComp = ((*uart::truma_IDFUARTComponent) this->parent_); + + // Tweak the fifo settings so data is available as soon as the first byte is recieved. + // If not it will wait either until fifo is filled or a certain time has passed. + uart_intr_config_t uart_intr; + uart_intr.intr_enable_mask = 0; // UART_RXFIFO_FULL_INT_ENA_M | UART_RXFIFO_TOUT_INT_ENA_M | UART_FRM_ERR_INT_ENA_M | + // UART_RXFIFO_OVF_INT_ENA_M | UART_BRK_DET_INT_ENA_M | UART_PARITY_ERR_INT_ENA_M; + uart_intr.rxfifo_full_thresh = + 1; // UART_FULL_THRESH_DEFAULT, //120 default!! aghh! need receive 120 chars before we see them + uart_intr.rx_timeout_thresh = + 1; // UART_TOUT_THRESH_DEFAULT, //10 works well for my short messages I need send/receive + uart_intr.txfifo_empty_intr_thresh = 10; // UART_EMPTY_THRESH_DEFAULT + uart_intr_config(uartComp->get_hw_serial_number(), &uart_intr); +#else +// truma_RP2040UartComponent +#error Only ESP32 Arduino is supported. +#endif + + xTaskCreateUniversal(LinBusListener::read_task, + "read_task", // name + 4096, // stack size (in words) + this, // input params + 1, // priority + &this->read_task_handle, // handle + 0 // core + ); + + if (this->cs_pin_ != nullptr) { + this->cs_pin_->digital_write(true); + } +} + +void LinBusListener::write_lin_answer_(const u_int8_t *data, size_t len) { + if (!this->can_write_lin_answer_) { + ESP_LOGE(TAG, "Cannot answer LIN because there is no open order from master."); + return; + } + this->can_write_lin_answer_ = false; + if (len > 8) { + ESP_LOGE(TAG, "LIN answer cannot be longer than 8 bytes."); + return; + } + + int64_t wait_time = 0; + // Was data read from FIFO and the master is awaiting an answer. + if (this->total_wait_ > 1000) { + // I am up to date and should not answer too quickly. + auto current = esp_timer_get_time(); + auto wait_time_in_us = (int64_t) this->time_per_baud_ - (current - this->last_data_recieved_); + wait_time = wait_time_in_us; + if (wait_time_in_us > 1000 || wait_time_in_us < 0) { + wait_time_in_us = 0; + } + delayMicroseconds(wait_time_in_us); + } + + u_int8_t data_CRC = 0; + if (this->lin_checksum_ == LIN_CHECKSUM::LIN_CHECKSUM_VERSION_1 || this->current_PID_ == DIAGNOSTIC_FRAME_SLAVE) { + // LIN checksum V1 + data_CRC = data_checksum(data, len, 0); + } else { + // LIN checksum V2 + data_CRC = data_checksum(data, len, this->current_PID_with_parity_); + } + + if (!this->observer_mode_) { + this->write_array(data, len); + this->write(data_CRC); + this->flush(); + } + ESP_LOGV(TAG, "RESPONSE %02x %s %02x T %lli", this->current_PID_, format_hex_pretty(data, len).c_str(), data_CRC, + wait_time); +} + +void LinBusListener::read_task(void *params) { + LinBusListener *instance = (LinBusListener *) params; + + while (true) { + // Check if Lin Bus is faulty. + if (instance->fault_pin_ != nullptr) { + if (!instance->fault_pin_->digital_read()) { + if (!instance->fault_on_lin_bus_reported_) { + instance->fault_on_lin_bus_reported_ = true; + ESP_LOGE(TAG, "Fault on LIN BUS detected."); + } + // Ignore any data present in buffer + instance->clear_uart_buffer_(); + } else if (instance->fault_on_lin_bus_reported_) { + instance->fault_on_lin_bus_reported_ = false; + ESP_LOGI(TAG, "Fault on LIN BUS fixed."); + } + } + + if (!instance->fault_on_lin_bus_reported_) { + while (instance->available()) { + instance->read_lin_frame_(); + } + } + + // Check if CP Plus is inactive mode. In inactive mode it checks the bus every ~15 seconds for ~5 seconds. At the + // start it send a Break to notify devices to wake up. + auto time_since_last_activity = esp_timer_get_time() - instance->last_data_recieved_; + if (time_since_last_activity > 100 * 1000 /* 100 ms*/) { + // CP Plus is inactive. + delay(500); // NOLINT + } else { + // CP Plus is active. + // 1'000'000 ns / 9600 baud = 104 ns/baud * (8 bit + start bit + 2 stop bit) = 1144 ns/byte * 3 (BREAK,SYNC,PID) = + // ~3.5ms per preamble till I should answer. It is still working with 50ms. But thats the upper limit. CP Plus + // waits 50ms when ordering for an answer. With higher polling the number of CRC errors increases and I cannot + // answer lin orders. + delay(10); // NOLINT + } + } +} + +void LinBusListener::read_lin_frame_() { + u_int8_t buf; + bool dataRecieved; + u_int8_t data_length, data_CRC, data_CRC_master, data_CRC_slave; + bool message_source_know, message_from_master; + + // Reset current state + { + this->current_PID_with_parity_ = 0x00; + this->current_PID_ = 0x00; + this->current_data_valid = true; + this->current_data_count_ = 0; + memset(this->current_data_, 0, sizeof(this->current_data_)); + this->total_wait_ = 0; + } + + // First is Break expected + if (!this->read_byte(&buf) || buf != LIN_BREAK) { + // Update I recieved garbage + this->last_data_recieved_ = esp_timer_get_time(); + ESP_LOGVV(TAG, "Expected BREAK not received."); + return; + } + + // Update I recieved a break + this->last_data_recieved_ = esp_timer_get_time(); + + if (!this->wait_for_data_available_with_timeout_(this->time_per_lin_break_)) { + ESP_LOGV(TAG, "Timeout waiting for Sync"); + return; + } + + // Second is Sync expected + if (!this->read_byte(&buf) || buf != LIN_SYNC) { + // No data present on UART + ESP_LOGVV(TAG, "Expected SYNC not found."); + return; + } + + if (!this->wait_for_data_available_with_timeout_(this->time_per_pid_)) { + ESP_LOGVV(TAG, "Timeout waiting for PID."); + return; + } + + this->read_byte(&(this->current_PID_with_parity_)); + this->current_PID_ = this->current_PID_with_parity_ & 0x3F; + if (this->lin_checksum_ == LIN_CHECKSUM::LIN_CHECKSUM_VERSION_2) { + if (this->current_PID_with_parity_ != (this->current_PID_ | (addr_parity(this->current_PID_) << 6))) { + ESP_LOGW(TAG, "LIN CRC error"); + this->current_data_valid = false; + } + } + + this->can_write_lin_answer_ = true; + // Should I response to this PID order? Ask the handling class. + this->answer_lin_order_(this->current_PID_); + this->can_write_lin_answer_ = false; + + dataRecieved = wait_for_data_available_with_timeout_(this->time_per_first_byte_); + while (dataRecieved) { + this->read_byte(&buf); + if (this->current_data_count_ < sizeof(this->current_data_)) { + this->current_data_[this->current_data_count_] = buf; + this->current_data_count_++; + dataRecieved = wait_for_data_available_with_timeout_(this->time_per_byte_); + } else { + // end of data reached. There cannot be more than 9 bytes in a LIN frame. + dataRecieved = false; + } + } + + if (this->current_data_count_ > 1) { + data_length = this->current_data_count_ - 1; + data_CRC = this->current_data_[this->current_data_count_ - 1]; + message_source_know = false; + message_from_master = true; + + if (this->lin_checksum_ == LIN_CHECKSUM::LIN_CHECKSUM_VERSION_1 || + (this->current_PID_ == DIAGNOSTIC_FRAME_MASTER || this->current_PID_ == DIAGNOSTIC_FRAME_SLAVE)) { + if (data_CRC != data_checksum(this->current_data_, data_length, 0)) { + ESP_LOGW(TAG, "LIN v1 CRC error"); + this->current_data_valid = false; + } + if (this->current_PID_ == DIAGNOSTIC_FRAME_MASTER) { + message_source_know = true; + message_from_master = true; + } else if (this->current_PID_ == DIAGNOSTIC_FRAME_SLAVE) { + message_source_know = true; + message_from_master = false; + } + } else { + data_CRC_master = data_checksum(this->current_data_, data_length, this->current_PID_); + data_CRC_slave = data_checksum(this->current_data_, data_length, this->current_PID_with_parity_); + if (data_CRC != data_CRC_master && data_CRC != data_CRC_slave) { + ESP_LOGW(TAG, "LIN v2 CRC error"); + this->current_data_valid = false; + } + message_source_know = true; + if (data_CRC == data_CRC_slave) { + message_from_master = false; + } + } + + // Mark the PID of the TRUMA Combi heater as very verbose message. + if (this->current_PID_ == 0x20 || this->current_PID_ == 0x21 || this->current_PID_ == 0x22) { + ESP_LOGVV(TAG, "PID %02x (%02x) %s %s %s", this->current_PID_, this->current_PID_with_parity_, + format_hex_pretty(this->current_data_, this->current_data_count_).c_str(), + message_source_know ? (message_from_master ? " - MASTER" : " - SLAVE") : "", + this->current_data_valid ? "" : "INVALID"); + } else { + ESP_LOGV(TAG, "PID %02x (%02x) %s %s %S", this->current_PID_, this->current_PID_with_parity_, + format_hex_pretty(this->current_data_, this->current_data_count_).c_str(), + message_source_know ? (message_from_master ? " - MASTER" : " - SLAVE") : "", + this->current_data_valid ? "" : "INVALID"); + } + + if (this->current_data_valid && message_from_master) { + this->lin_message_recieved_(this->current_PID_, this->current_data_, data_length); + } + } else { + ESP_LOGV(TAG, "PID %02x (%02x) order no answer", this->current_PID_, this->current_PID_with_parity_); + } +} + +void LinBusListener::clear_uart_buffer_() { + u_int8_t buffer; + while (this->available() && this->read_byte(&buffer)) { + } +} + +bool LinBusListener::wait_for_data_available_with_timeout_(u_int32_t timeout) { + int64_t start = esp_timer_get_time(); + int64_t current = esp_timer_get_time(); + int64_t latest_end = start + timeout; + while (current < latest_end) { + current = esp_timer_get_time(); + if (this->available()) { + this->total_wait_ += current - start; + this->last_data_recieved_ = current; + return true; + } + NOP(); + } + return false; +} + +} // namespace truma_inetbox +} // namespace esphome diff --git a/components/truma_inetbox/LinBusListener.h b/components/truma_inetbox/LinBusListener.h new file mode 100644 index 0000000..bec479f --- /dev/null +++ b/components/truma_inetbox/LinBusListener.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +#ifdef USE_ESP32 +#include +#include +#endif + +namespace esphome { +namespace truma_inetbox { + +enum class LIN_CHECKSUM { LIN_CHECKSUM_VERSION_1, LIN_CHECKSUM_VERSION_2 }; + +class LinBusListener : public PollingComponent, public uart::UARTDevice { + public: + float get_setup_priority() const override { return setup_priority::DATA; } + + void dump_config() override; + void setup() override; + + void set_lin_checksum(LIN_CHECKSUM val) { this->lin_checksum_ = val; } + void set_cs_pin(GPIOPin *pin) { this->cs_pin_ = pin; } + void set_fault_pin(GPIOPin *pin) { this->fault_pin_ = pin; } + void set_observer_mode(bool val) { this->observer_mode_ = val; } + bool get_lin_bus_fault() { return fault_on_lin_bus_reported_; } + + protected: + LIN_CHECKSUM lin_checksum_; + GPIOPin *cs_pin_; + GPIOPin *fault_pin_; + bool observer_mode_ = false; + + void write_lin_answer_(const u_int8_t *data, size_t len); + + virtual bool answer_lin_order_(const u_int8_t pid) = 0; + virtual void lin_message_recieved_(const u_int8_t pid, const u_int8_t *message, u_int8_t length) = 0; + + private: + // Microseconds per UART Baud + u_int32_t time_per_baud_; + // 9.. 15 + u_int8_t lin_break_length = 13; + // Microseconds per LIN Break + u_int32_t time_per_lin_break_; + u_int8_t frame_length_ = (8 /* bits */ + 1 /* Start bit */ + 2 /* Stop bits */); + // Microseconds per UART Byte (UART Frame) + u_int32_t time_per_pid_; + // Microseconds per UART Byte (UART Frame) + u_int32_t time_per_first_byte_; + // Microseconds per UART Byte (UART Frame) + u_int32_t time_per_byte_; + + bool fault_on_lin_bus_reported_ = false; + bool can_write_lin_answer_ = false; + + u_int8_t current_PID_with_parity_ = 0x00; + u_int8_t current_PID_ = 0x00; + bool current_data_valid = true; + u_int8_t current_data_count_ = 0; + // up to 8 byte data frame + CRC + u_int8_t current_data_[9] = {}; + // Total wait time for this LIN Frame (Break, SYNC, Data, CRC) + u_int32_t total_wait_; + // Time when the last LIN data was available. + int64_t last_data_recieved_; + + TaskHandle_t read_task_handle = NULL; + static void read_task(void *params); + + void read_lin_frame_(); + void clear_uart_buffer_(); + bool wait_for_data_available_with_timeout_(u_int32_t timeout); +}; + +} // namespace truma_inetbox +} // namespace esphome diff --git a/components/truma_inetbox/LinBusProtocol.cpp b/components/truma_inetbox/LinBusProtocol.cpp new file mode 100644 index 0000000..fe22e6f --- /dev/null +++ b/components/truma_inetbox/LinBusProtocol.cpp @@ -0,0 +1,254 @@ +#include "LinBusProtocol.h" +#include +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.LinBusProtocol"; + +#define DIAGNOSTIC_FRAME_MASTER 0x3c +#define DIAGNOSTIC_FRAME_SLAVE 0x3d +#define LIN_NAD_BROADCAST 0x7F +#define LIN_SID_RESPONSE 0x40 +#define LIN_SID_ASSIGN_NAD 0xB0 +#define LIN_SID_ASSIGN_NAD_RESPONSE (LIN_SID_ASSIGN_NAD | LIN_SID_RESPONSE) +#define LIN_SID_READ_BY_IDENTIFIER 0xB2 +#define LIN_SID_READ_BY_IDENTIFIER_RESPONSE (LIN_SID_READ_BY_IDENTIFIER | LIN_SID_RESPONSE) +#define LIN_SID_HEARTBEAT 0xB9 +#define LIN_SID_HEARTBEAT_RESPONSE (LIN_SID_HEARTBEAT | LIN_SID_RESPONSE) +#define LIN_SID_READ_STATE_BUFFER 0xBA +#define LIN_SID_READ_STATE_BUFFER_RESPONSE (LIN_SID_READ_STATE_BUFFER | LIN_SID_RESPONSE) +#define LIN_SID_FIll_STATE_BUFFFER 0xBB +#define LIN_SID_FIll_STATE_BUFFFER_BRESPONSE (LIN_SID_FIll_STATE_BUFFFER | LIN_SID_RESPONSE) + +bool LinBusProtocol::answer_lin_order_(const u_int8_t pid) { + // Send requested answer + if (pid == DIAGNOSTIC_FRAME_SLAVE) { + if (!this->updates_to_send_.empty()) { + auto update_to_send_ = this->updates_to_send_.front(); + this->updates_to_send_.pop(); + this->write_lin_answer_(update_to_send_.data(), update_to_send_.size()); + return true; + } + } + return false; +} + +void LinBusProtocol::lin_message_recieved_(const u_int8_t pid, const u_int8_t *message, u_int8_t length) { + if (pid == DIAGNOSTIC_FRAME_MASTER) { + // The original Inet Box is answering this message. Works fine without. + // std::array message_array = {}; + // std::copy(message, message + length, message_array.begin()); + // if (message_array == this->lin_empty_response_) { + // std::array response = this->lin_empty_response_; + // response[0] = 0x00; + // response[1] = 0x55; + // response[2] = 0x03; // this->lin_node_address_; + // response[3] = 0x66; + // response[4] = 0x5B; + // response[5] = 0xA7; + // response[6] = 0x0E; + // response[7] = 0x49; + // this->prepare_update_msg_(response); + // } + + this->lin_message_recieved_diagnostic_(message, length); + + } else if (pid == this->lin_node_address_) { + ESP_LOGW(TAG, "Unhandled message for me."); + } +} + +void LinBusProtocol::prepare_update_msg_(const std::array message) { + this->updates_to_send_.push(message); +} + +bool LinBusProtocol::is_matching_identifier_(const u_int8_t *message) { + auto lin_identifier = this->lin_identifier(); + return message[0] == lin_identifier[0] && message[1] == lin_identifier[1] && message[2] == lin_identifier[2] && + message[3] == lin_identifier[3]; +} + +void LinBusProtocol::lin_message_recieved_diagnostic_(const u_int8_t *message, u_int8_t length) { + u_int8_t node_address = message[0]; + bool my_node_address = node_address == this->lin_node_address_; + bool broadcast_address = node_address == LIN_NAD_BROADCAST; + if (!my_node_address && !broadcast_address) { + return; + } + u_int8_t protocol_control_information = message[1]; + u_int16_t message_length = 0; + u_int8_t service_identifier = 0; + if ((protocol_control_information & 0xF0) == 0x00) { + // Single Frame mode + { + // End any open Multi frame mode message + this->multi_pdu_message_expected_size_ = 0; + this->multi_pdu_message_len_ = 0; + } + message_length = protocol_control_information; + service_identifier = message[2]; + if (message_length > 6) { + ESP_LOGE(TAG, "LIN Protocol issue: Single frame message too long."); + // ignore invalid message + return; + } + } else if ((protocol_control_information & 0xF0) == 0x10) { + // First Frame of multi PDU message + message_length = (protocol_control_information & 0x0F << 8) + message[2]; + service_identifier = message[3]; + if (message_length < 7) { + ESP_LOGE(TAG, "LIN Protocol issue: Multi frame message too short."); + // ignore invalid message + return; + } + if (message_length > sizeof(this->multi_pdu_message_)) { + ESP_LOGE(TAG, "LIN Protocol issue: Multi frame message too long."); + // ignore invalid message + return; + } + this->multi_pdu_message_expected_size_ = message_length; + this->multi_pdu_message_len_ = 0; + for (size_t i = 3; i < 8; i++) { + this->multi_pdu_message_[this->multi_pdu_message_len_++] = message[i]; + } + // Message is handeld + return; + } else if ((protocol_control_information & 0xF0) == 0x20) { + // Consecutive Frames + if (this->multi_pdu_message_len_ >= this->multi_pdu_message_expected_size_) { + // ignore, because i don't await a consecutive frame + return; + } + this->lin_message_recieved_diagnostic_multi_(message, length, protocol_control_information); + // Message is handeld + return; + } + + if (service_identifier == LIN_SID_READ_BY_IDENTIFIER && message_length == 6) { + if (this->is_matching_identifier_(&message[4])) { + // I have observed the following identifiers: + // broadcast_address: + // - 0x00 - response lin_identifier[0:4] + 0x00 /* Hardware revision*/ + // my_node_address: + // - 0x20 - displayed version + // - 0x22 - unkown + auto identifier = message[3]; + std::array response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + + std::array identifier_response = {}; + if (this->lin_read_field_by_identifier_(identifier, &identifier_response)) { + response[1] = 6; /* bytes length - ignored by CP Plus?*/ + response[2] = LIN_SID_READ_BY_IDENTIFIER_RESPONSE; + auto iterator = response.begin(); + std::advance(iterator, 3); + std::copy(identifier_response.data(), identifier_response.data() + identifier_response.size(), iterator); + } else { + // Not supported - Negative response (see 4.2.6.1 Read by identifier) + response[1] = 3; /* bytes length*/ + response[2] = 0x7F; + response[3] = LIN_SID_READ_BY_IDENTIFIER; + response[4] = 0x12; + } + this->prepare_update_msg_(response); + } + } else if (my_node_address && service_identifier == LIN_SID_HEARTBEAT && message_length >= 5) { + // if (message[3] == 0x00 && message[4] == 0x1F && message[5] == 0x00 && message[6] == 0x00) { + std::array response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + response[1] = 2; /* bytes length*/ + response[2] = LIN_SID_HEARTBEAT_RESPONSE; + response[3] = 0x00; + this->prepare_update_msg_(response); + //} + } else if (broadcast_address && service_identifier == LIN_SID_ASSIGN_NAD && message_length == 6) { + if (this->is_matching_identifier_(&message[3])) { + ESP_LOGI(TAG, "Assigned new SID %02x and reset device", message[7]); + + // send response with old node address. + std::array response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + response[1] = 1; /* bytes length*/ + response[2] = LIN_SID_ASSIGN_NAD_RESPONSE; + this->prepare_update_msg_(response); + this->lin_node_address_ = message[7]; + + // assumption an assign new SID occurs as part of init process. + this->lin_reset_device(); + } + } else { + if (my_node_address) { + ESP_LOGD(TAG, "SID %02x MY - %s - Unhandled", service_identifier, format_hex_pretty(message, length).c_str()); + } else if (broadcast_address) { + ESP_LOGD(TAG, "SID %02x BC - %s - Unhandled", service_identifier, format_hex_pretty(message, length).c_str()); + } + } +} + +void LinBusProtocol::lin_message_recieved_diagnostic_multi_(const u_int8_t *message, u_int8_t length, + u_int8_t protocol_control_information) { + u_int8_t frame_counter = protocol_control_information - 0x21; + for (size_t i = 2; i < 8; i++) { + if (this->multi_pdu_message_len_ < this->multi_pdu_message_expected_size_) { + this->multi_pdu_message_[this->multi_pdu_message_len_++] = message[i]; + } + } + if (this->multi_pdu_message_len_ == this->multi_pdu_message_expected_size_) { + ESP_LOGD(TAG, "Multi package request %s", + format_hex_pretty(this->multi_pdu_message_, this->multi_pdu_message_len_).c_str()); + + u_int8_t answer_len = 0; + auto answer = this->lin_multiframe_recieved(this->multi_pdu_message_, this->multi_pdu_message_len_, &answer_len); + if (answer_len > 0) { + ESP_LOGD(TAG, "Multi package response %s", format_hex_pretty(answer, answer_len).c_str()); + + if (answer_len <= 6) { + // Single Frame response + std::array response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + response[1] = answer_len; /* bytes length */ + response[2] = answer[0] | LIN_SID_RESPONSE; + for (size_t i = 1; i < answer_len; i++) { + response[i + 2] = answer[i]; + } + this->prepare_update_msg_(response); + } else { + // Multi Frame response + std::array response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + response[1] = 0x10 | ((answer_len >> 8) & 0x0F); + response[2] = answer_len & 0xFF; + response[3] = answer[0] | LIN_SID_RESPONSE; + for (size_t i = 1; i < 5; i++) { + response[i + 3] = answer[i]; + } + this->prepare_update_msg_(response); + + u_int16_t answer_position = 5; // The first 5 bytes are sent in First frame of multi frame response. + u_int8_t answer_frame_counter = 0; // Each answer frame can contain 6 bytes + while (answer_position < answer_len) { + response = this->lin_empty_response_; + response[0] = this->lin_node_address_; + response[1] = ((answer_frame_counter + 1) & 0x0F) | 0x20; + for (size_t i = 0; i < 6; i++) { + if (answer_position < answer_len) { + response[i + 2] = answer[answer_position++]; + } + } + this->prepare_update_msg_(response); + answer_frame_counter++; + } + } + } + } +} + +void LinBusProtocol::lin_message_recieved_diagnostic_single_(const u_int8_t *message, u_int8_t length) { + // TODO: Split up `lin_message_recieved_diagnostic_` method. +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/LinBusProtocol.h b/components/truma_inetbox/LinBusProtocol.h new file mode 100644 index 0000000..08a17fb --- /dev/null +++ b/components/truma_inetbox/LinBusProtocol.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include "LinBusListener.h" + +namespace esphome { +namespace truma_inetbox { +class LinBusProtocol : public LinBusListener { + public: + virtual const std::array lin_identifier() = 0; + virtual void lin_reset_device() = 0; + + protected: + const std::array lin_empty_response_ = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + bool answer_lin_order_(const u_int8_t pid) override; + void lin_message_recieved_(const u_int8_t pid, const u_int8_t *message, u_int8_t length) override; + + virtual bool lin_read_field_by_identifier_(u_int8_t identifier, std::array *response) = 0; + virtual const u_int8_t *lin_multiframe_recieved(const u_int8_t *message, const u_int8_t message_len, + u_int8_t *return_len) = 0; + + std::queue> updates_to_send_ = {}; + + private: + u_int8_t lin_node_address_ = /*LIN initial node address*/ 0x03; + + void prepare_update_msg_(const std::array message); + bool is_matching_identifier_(const u_int8_t *message); + + u_int16_t multi_pdu_message_expected_size_ = 0; + u_int8_t multi_pdu_message_len_ = 0; + u_int8_t multi_pdu_message_[64]; + void lin_message_recieved_diagnostic_(const u_int8_t *message, u_int8_t length); + void lin_message_recieved_diagnostic_multi_(const u_int8_t *message, u_int8_t length, + u_int8_t protocol_control_information); + void lin_message_recieved_diagnostic_single_(const u_int8_t *message, u_int8_t length); +}; + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/TrumaStatusFrame.cpp b/components/truma_inetbox/TrumaStatusFrame.cpp new file mode 100644 index 0000000..5e6397d --- /dev/null +++ b/components/truma_inetbox/TrumaStatusFrame.cpp @@ -0,0 +1,86 @@ +#include "TrumaStatusFrame.h" +#include "esphome/core/helpers.h" +#include "TrumaiNetBoxApp.h" +#include "helpers.h" + +namespace esphome { +namespace truma_inetbox { +void status_frame_create_empty(StatusFrame *response, u_int8_t message_type, u_int8_t message_length, + u_int8_t command_counter) { + response->inner.genericHeader.service_identifier = LIN_SID_READ_STATE_BUFFER | LIN_SID_RESPONSE; + // Copy header over for this message. + for (size_t i = 1; i < truma_message_header.size(); i++) { + response->raw[i] = truma_message_header[i]; + } + response->inner.genericHeader.header_2 = 'T'; + response->inner.genericHeader.header_3 = 0x01; + response->inner.genericHeader.message_type = message_type; + response->inner.genericHeader.message_length = message_length; + response->inner.genericHeader.command_counter = command_counter; +} + +void status_frame_calculate_checksum(StatusFrame *response) { + response->inner.genericHeader.checksum = 0x0; + response->inner.genericHeader.checksum = data_checksum(&response->raw[10], sizeof(StatusFrame) - 10, 0); +} + +void status_frame_create_init(StatusFrame *response, u_int8_t command_counter) { + status_frame_create_empty(response, STATUS_FRAME_RESPONSE_INIT_REQUEST, 0, command_counter); + + status_frame_calculate_checksum(response); +} + +void status_frame_create_update_clock(StatusFrame *response, u_int8_t command_counter, u_int8_t hour, u_int8_t minute, + u_int8_t second, ClockMode clockMode) { + status_frame_create_empty(response, STATUS_FRAME_CLOCK_RESPONSE, sizeof(StatusFrameClock), command_counter); + + response->inner.clock.clock_hour = hour; + response->inner.clock.clock_minute = minute; + response->inner.clock.clock_second = second; + response->inner.clock.display_1 = 0x1; + response->inner.clock.display_2 = 0x1; + response->inner.clock.clock_mode = clockMode; + + status_frame_calculate_checksum(response); +} + +void status_frame_create_update_timer(StatusFrame *response, u_int8_t command_counter, TimerActive active, + u_int8_t start_hour, u_int8_t start_minute, u_int8_t stop_hour, + u_int8_t stop_minute, TargetTemp room, TargetTemp water, HeatingMode mode, + EnergyMix energy, ElectricPowerLevel elPower) { + status_frame_create_empty(response, STATUS_FRAME_TIMER_RESPONSE, sizeof(StatusFrameTimerResponse), command_counter); + + response->inner.timerResponse.timer_target_temp_room = room; + response->inner.timerResponse.timer_heating_mode = mode; + response->inner.timerResponse.timer_target_temp_water = water; + response->inner.timerResponse.timer_energy_mix_a = energy; + response->inner.timerResponse.timer_energy_mix_b = energy; + response->inner.timerResponse.timer_el_power_level_a = elPower; + response->inner.timerResponse.timer_el_power_level_b = elPower; + response->inner.timerResponse.timer_resp_active = active; + response->inner.timerResponse.timer_resp_start_hours = start_hour; + response->inner.timerResponse.timer_resp_start_minutes = start_minute; + response->inner.timerResponse.timer_resp_stop_hours = stop_hour; + response->inner.timerResponse.timer_resp_stop_minutes = stop_minute; + + status_frame_calculate_checksum(response); +} + +void status_frame_create_update_heater(StatusFrame *response, u_int8_t command_counter, TargetTemp room, + TargetTemp water, HeatingMode mode, EnergyMix energy, + ElectricPowerLevel elPower) { + status_frame_create_empty(response, STATUS_FRAME_HEATER_RESPONSE, sizeof(StatusFrameHeaterResponse), command_counter); + + response->inner.heaterResponse.target_temp_room = room; + response->inner.heaterResponse.heating_mode = mode; + response->inner.heaterResponse.target_temp_water = water; + response->inner.heaterResponse.energy_mix_a = energy; + response->inner.heaterResponse.energy_mix_b = energy; + response->inner.heaterResponse.el_power_level_a = elPower; + response->inner.heaterResponse.el_power_level_b = elPower; + + status_frame_calculate_checksum(response); +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/TrumaStatusFrame.h b/components/truma_inetbox/TrumaStatusFrame.h new file mode 100644 index 0000000..45873c8 --- /dev/null +++ b/components/truma_inetbox/TrumaStatusFrame.h @@ -0,0 +1,28 @@ +#pragma once + +#include "TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +void status_frame_create_empty(StatusFrame *response, u_int8_t message_type, u_int8_t message_length, + u_int8_t command_counter); + +void status_frame_calculate_checksum(StatusFrame *response); + +void status_frame_create_init(StatusFrame *response, u_int8_t command_counter); + +void status_frame_create_update_clock(StatusFrame *response, u_int8_t command_counter, u_int8_t hour, u_int8_t minute, + u_int8_t second, ClockMode clockMode); + +void status_frame_create_update_timer(StatusFrame *response, u_int8_t command_counter, TimerActive active, + u_int8_t start_hour, u_int8_t start_minute, u_int8_t stop_hour, + u_int8_t stop_minute, TargetTemp room, TargetTemp water, HeatingMode mode, + EnergyMix energy, ElectricPowerLevel elPower); + +void status_frame_create_update_heater(StatusFrame *response, u_int8_t command_counter, TargetTemp room, + TargetTemp water, HeatingMode mode, EnergyMix energy, + ElectricPowerLevel elPower); + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/TrumaiNetBoxApp.cpp b/components/truma_inetbox/TrumaiNetBoxApp.cpp new file mode 100644 index 0000000..9480f85 --- /dev/null +++ b/components/truma_inetbox/TrumaiNetBoxApp.cpp @@ -0,0 +1,439 @@ +#include "TrumaiNetBoxApp.h" +#include "TrumaStatusFrame.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.TrumaiNetBoxApp"; + +TrumaiNetBoxApp::TrumaiNetBoxApp(u_int8_t expected_listener_count) { + this->listeners_heater_.reserve(expected_listener_count); +} + +void TrumaiNetBoxApp::update() { + // Call listeners in after method 'lin_multiframe_recieved' call. + // Because 'lin_multiframe_recieved' is time critical an all these sensors can take some time. + if (this->status_heater_updated_ || this->status_timer_updated_ || this->status_clock_updated_ || + this->status_config_updated_) { + // Run through listeners + for (auto &listener : this->listeners_heater_) { + if (this->status_heater_updated_ && listener.on_heater_change != nullptr) { + listener.on_heater_change(&this->status_heater_); + } + if (this->status_timer_updated_ && listener.on_timer_change != nullptr) { + listener.on_timer_change(&this->status_timer_); + } + if (this->status_clock_updated_ && listener.on_clock_change != nullptr) { + listener.on_clock_change(&this->status_clock_); + } + if (this->status_config_updated_ && listener.on_config_change != nullptr) { + listener.on_config_change(&this->status_config_); + } + } + + // Run through callbacks + if (this->status_heater_updated_) { + this->state_heater_callback_.call(&this->status_heater_); + } + // update is handeld + this->status_heater_updated_ = false; + this->status_timer_updated_ = false; + this->status_clock_updated_ = false; + this->status_config_updated_ = false; + } +} + +const std::array TrumaiNetBoxApp::lin_identifier() { + // Unkown: + // 17.46.01.03 - Unkown more comms required for init. + // 17.46.10.03 - Unkown more comms required for init. + // Heater: + // 17.46.40.03 - H2.00.01 - 0340.xx Combi 4/6 + // Aircon: + // 17.46.00.0C - A23.70.0 - 0C00.xx (with light option: OFF/1..5) + // 17.46.01.0C - A23.70.0 - 0C01.xx + // 17.46.04.0C - A23.70.0 - 0C04.xx (with light option: OFF/1..5) + // 17.46.05.0C - A23.70.0 - 0C05.xx + // 17.46.06.0C - A23.70.0 - 0C06.xx (with light option: OFF/1..5) + // 17.46.07.0C - A23.70.0 - 0C07.xx (with light option: OFF/1..5) + // iNet Box: + // 17.46.00.1F - T23.70.0 - 1F00.xx iNet Box + return {0x17 /*Supplied Id*/, 0x46 /*Supplied Id*/, 0x00 /*Function Id*/, 0x1F /*Function Id*/}; +} + +void TrumaiNetBoxApp::lin_reset_device() { + this->device_registered_ = esp_timer_get_time(); + this->init_recieved_ = 0; + + this->status_heater_valid_ = false; + this->status_heater_updated_ = false; + this->status_timer_valid_ = false; + this->status_timer_updated_ = false; + this->status_clock_valid_ = false; + this->status_clock_updated_ = false; + this->status_config_valid_ = false; + this->status_config_updated_ = false; + + this->update_time_ = 0; + + this->update_status_heater_prepared_ = false; + this->update_status_heater_unsubmitted_ = false; + this->update_status_heater_stale_ = false; + + this->update_status_timer_prepared_ = false; + this->update_status_timer_unsubmitted_ = false; + this->update_status_timer_stale_ = false; +} + +void TrumaiNetBoxApp::register_listener(const std::function &func) { + auto listener = StatusFrameListener{ + .on_heater_change = func, + }; + this->listeners_heater_.push_back(std::move(listener)); + + if (this->status_heater_valid_) { + func(&this->status_heater_); + } +} +void TrumaiNetBoxApp::register_listener(const std::function &func) { + auto listener = StatusFrameListener{ + .on_timer_change = func, + }; + this->listeners_heater_.push_back(std::move(listener)); + + if (this->status_timer_valid_) { + func(&this->status_timer_); + } +} +void TrumaiNetBoxApp::register_listener(const std::function &func) { + auto listener = StatusFrameListener{ + .on_clock_change = func, + }; + this->listeners_heater_.push_back(std::move(listener)); + + if (this->status_clock_valid_) { + func(&this->status_clock_); + } +} +void TrumaiNetBoxApp::register_listener(const std::function &func) { + auto listener = StatusFrameListener{ + .on_config_change = func, + }; + this->listeners_heater_.push_back(std::move(listener)); + + if (this->status_config_valid_) { + func(&this->status_config_); + } +} + +StatusFrameHeaterResponse *TrumaiNetBoxApp::update_heater_prepare() { + // An update is currently going on. + if (this->update_status_heater_prepared_ || this->update_status_heater_stale_) { + return &this->update_status_heater_; + } + + // prepare status heater response + this->update_status_heater_ = {}; + this->update_status_heater_.target_temp_room = this->status_heater_.target_temp_room; + this->update_status_heater_.heating_mode = this->status_heater_.heating_mode; + this->update_status_heater_.el_power_level_a = this->status_heater_.el_power_level_a; + this->update_status_heater_.target_temp_water = this->status_heater_.target_temp_water; + this->update_status_heater_.el_power_level_b = this->status_heater_.el_power_level_b; + this->update_status_heater_.energy_mix_a = this->status_heater_.energy_mix_a; + this->update_status_heater_.energy_mix_b = this->status_heater_.energy_mix_b; + + this->update_status_heater_prepared_ = true; + return &this->update_status_heater_; +} + +StatusFrameTimerResponse *TrumaiNetBoxApp::update_timer_prepare() { + // An update is currently going on. + if (this->update_status_timer_prepared_ || this->update_status_timer_stale_) { + return &this->update_status_timer_; + } + + // prepare status heater response + this->update_status_timer_ = {}; + this->update_status_timer_.timer_target_temp_room = this->status_timer_.timer_target_temp_room; + this->update_status_timer_.timer_heating_mode = this->status_timer_.timer_heating_mode; + this->update_status_timer_.timer_el_power_level_a = this->status_timer_.timer_el_power_level_a; + this->update_status_timer_.timer_target_temp_water = this->status_timer_.timer_target_temp_water; + this->update_status_timer_.timer_el_power_level_b = this->status_timer_.timer_el_power_level_b; + this->update_status_timer_.timer_energy_mix_a = this->status_timer_.timer_energy_mix_a; + this->update_status_timer_.timer_energy_mix_b = this->status_timer_.timer_energy_mix_b; + this->update_status_timer_.timer_resp_active = this->status_timer_.timer_active; + this->update_status_timer_.timer_resp_start_minutes = this->status_timer_.timer_start_minutes; + this->update_status_timer_.timer_resp_start_hours = this->status_timer_.timer_start_hours; + this->update_status_timer_.timer_resp_stop_minutes = this->status_timer_.timer_stop_minutes; + this->update_status_timer_.timer_resp_stop_hours = this->status_timer_.timer_stop_hours; + + this->update_status_timer_prepared_ = true; + return &this->update_status_timer_; +} + +bool TrumaiNetBoxApp::answer_lin_order_(const u_int8_t pid) { + // Alive message + if (pid == LIN_PID_TRUMA_INET_BOX) { + std::array response = this->lin_empty_response_; + + if (this->updates_to_send_.empty() && !this->has_update_to_submit_()) { + response[0] = 0xFE; + } + this->write_lin_answer_(response.data(), sizeof(response)); + return true; + } + return LinBusProtocol::answer_lin_order_(pid); +} + +bool TrumaiNetBoxApp::lin_read_field_by_identifier_(u_int8_t identifier, std::array *response) { + this->device_registered_ = esp_timer_get_time(); + if (identifier == 0x00 /* LIN Product Identification */) { + auto lin_identifier = this->lin_identifier(); + (*response)[0] = lin_identifier[0]; + (*response)[1] = lin_identifier[1]; + (*response)[2] = lin_identifier[2]; + (*response)[3] = lin_identifier[3]; + (*response)[4] = 0x01; // Hardware revision + return true; + } else if (identifier == 0x20 /* Product details to display in CP plus */) { + auto lin_identifier = this->lin_identifier(); + // Only the first three parts are used. + (*response)[0] = lin_identifier[0]; + (*response)[1] = lin_identifier[1]; + (*response)[2] = lin_identifier[2]; + // (*response)[3] = // unkown + // (*response)[4] = // unkown + return true; + } else if (identifier == 0x22 /* unkown usage */) { + // Init is failing if missing + // Data can be anything? + return true; + } + return false; +} + +const u_int8_t *TrumaiNetBoxApp::lin_multiframe_recieved(const u_int8_t *message, const u_int8_t message_len, + u_int8_t *return_len) { + static u_int8_t response[48] = {}; + // Validate message prefix. + if (message_len < truma_message_header.size()) { + return nullptr; + } + for (u_int8_t i = 1; i < truma_message_header.size(); i++) { + if (message[i] != truma_message_header[i]) { + return nullptr; + } + } + + if (message[0] == LIN_SID_READ_STATE_BUFFER) { + // Example: BA.00.1F.00.1E.00.00.22.FF.FF.FF (11) + memset(response, 0, sizeof(response)); + auto response_frame = reinterpret_cast(response); + + // The order must match with the method 'has_update_to_submit_'. + if (this->init_recieved_ == 0) { + status_frame_create_init(response_frame, this->message_counter++); + // TODO: can I create a shorter (quicker) messsage here? + (*return_len) = sizeof(StatusFrame); + return response; + } + if (this->update_status_heater_unsubmitted_) { + status_frame_create_update_heater( + response_frame, this->message_counter++, this->update_status_heater_.target_temp_room, + this->update_status_heater_.target_temp_water, this->update_status_heater_.heating_mode, + this->update_status_heater_.energy_mix_a, this->update_status_heater_.el_power_level_a); + + this->update_time_ = 0; + this->update_status_heater_prepared_ = false; + this->update_status_heater_unsubmitted_ = false; + this->update_status_heater_stale_ = true; + // Remove last 12 bytes (2 Frames), because they are always 0. + // This cannot be done on the first message, but later messages it is fine. + (*return_len) = sizeof(StatusFrame); + return response; + } + if (this->update_status_timer_unsubmitted_) { + status_frame_create_update_timer( + response_frame, this->message_counter++, this->update_status_timer_.timer_resp_active, + this->update_status_timer_.timer_resp_start_hours, this->update_status_timer_.timer_resp_start_minutes, + this->update_status_timer_.timer_resp_stop_hours, this->update_status_timer_.timer_resp_stop_minutes, + this->update_status_timer_.timer_target_temp_room, this->update_status_timer_.timer_target_temp_water, + this->update_status_timer_.timer_heating_mode, this->update_status_timer_.timer_energy_mix_a, + this->update_status_timer_.timer_el_power_level_a); + + this->update_time_ = 0; + this->update_status_timer_prepared_ = false; + this->update_status_timer_unsubmitted_ = false; + this->update_status_timer_stale_ = true; + (*return_len) = sizeof(StatusFrame); + return response; + } + if (this->update_status_clock_unsubmitted_) { + // read time live + auto now = this->time_->now(); + + status_frame_create_update_clock(response_frame, this->message_counter++, now.hour, now.minute, now.second, + this->status_clock_.clock_mode); + + this->update_status_clock_unsubmitted_ = false; + (*return_len) = sizeof(StatusFrame); + return response; + } + } + + if (message_len < sizeof(StatusFrame) && message[0] == LIN_SID_FIll_STATE_BUFFFER) { + return nullptr; + } + + auto statusFrame = reinterpret_cast(message); + auto header = &statusFrame->inner.genericHeader; + // Validate Truma frame checksum + if (header->checksum != data_checksum(&statusFrame->raw[10], sizeof(StatusFrame) - 10, (0xFF - header->checksum)) || + header->header_2 != 'T' || header->header_3 != 0x01) { + ESP_LOGE(TAG, "Truma checksum fail."); + return nullptr; + } + + // create acknowledge response. + response[0] = (header->service_identifier | LIN_SID_RESPONSE); + (*return_len) = 1; + + if (header->message_type == STATUS_FRAME_HEATER && header->message_length == sizeof(StatusFrameHeater)) { + ESP_LOGI(TAG, "StatusFrameHeater"); + // Example: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->|tRoom|mo| |elecA|tWate|elecB|mi|mi|cWate|cRoom|st|err | | + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.14.33.00.12.00.00.00.00.00.00.00.00.00.00.01.01.CC.0B.6C.0B.00.00.00.00 + this->status_heater_ = statusFrame->inner.heater; + this->status_heater_valid_ = true; + this->status_heater_updated_ = true; + + this->update_status_heater_stale_ = false; + return response; + } else if (header->message_type == STATUS_FRAME_TIMER && header->message_length == sizeof(StatusFrameTimer)) { + ESP_LOGI(TAG, "StatusFrameTimer"); + // EXAMPLE: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->| + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.18.3D.00.1D.18.0B.01.00.00.00.00.00.00.00.01.01.00.00.00.00.00.00.00.01.00.08.00.09 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.18.3D.00.13.18.0B.0B.00.00.00.00.00.00.00.01.01.00.00.00.00.00.00.00.01.00.08.00.09 + this->status_timer_ = statusFrame->inner.timer; + this->status_timer_valid_ = true; + this->status_timer_updated_ = true; + + this->update_status_timer_stale_ = false; + + ESP_LOGV(TAG, "StatusFrameTimer target_temp_room: %f target_temp_water: %f %u:%u -> %u:%u %s", + temp_code_to_decimal(this->status_timer_.timer_target_temp_room), + temp_code_to_decimal(this->status_timer_.timer_target_temp_water), this->status_timer_.timer_start_hours, + this->status_timer_.timer_start_minutes, this->status_timer_.timer_stop_hours, + this->status_timer_.timer_stop_minutes, ((u_int8_t) this->status_timer_.timer_active ? " ON" : " OFF")); + + return response; + } else if (header->message_type == STATUS_FRAME_RESPONSE_ACK && + header->message_length == sizeof(StatusFrameResponseAck)) { + // Example: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->| + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.02.0D.01.98.02.00 + auto data = statusFrame->inner.responseAck; + + if (data.error_code != ResponseAckResult::RESPONSE_ACK_RESULT_OKAY) { + ESP_LOGW(TAG, "StatusFrameResponseAck"); + } else { + ESP_LOGI(TAG, "StatusFrameResponseAck"); + } + ESP_LOGV(TAG, "StatusFrameResponseAck %02x %s %02x", statusFrame->inner.genericHeader.command_counter, + data.error_code == ResponseAckResult::RESPONSE_ACK_RESULT_OKAY ? " OKAY " : " FAILED ", + (u_int8_t) data.error_code); + + if (data.error_code != ResponseAckResult::RESPONSE_ACK_RESULT_OKAY) { + // I tried to update something and it failed. Read current state again to validate and hold any updates for now. + this->lin_reset_device(); + } + + return response; + } else if (header->message_type == STATUS_FRAME_CLOCK && header->message_length == sizeof(StatusFrameClock)) { + ESP_LOGI(TAG, "StatusFrameClock"); + // Example: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->| + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.15.00.5B.0D.20.00.01.01.00.00.01.00.00 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.15.00.71.16.00.00.01.01.00.00.02.00.00 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.15.00.2B.16.1F.28.01.01.00.00.01.00.00 + this->status_clock_ = statusFrame->inner.clock; + this->status_clock_valid_ = true; + this->status_clock_updated_ = true; + + ESP_LOGV(TAG, "StatusFrameClock %02d:%02d:%02d", this->status_clock_.clock_hour, this->status_clock_.clock_minute, + this->status_clock_.clock_second); + + return response; + } else if (header->message_type == STAUTS_FRAME_CONFIG && header->message_length == sizeof(StatusFrameConfig)) { + ESP_LOGI(TAG, "StatusFrameConfig"); + // Example: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->| + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.17.00.0F.06.01.B4.0A.AA.0A.00.00.00.00 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.17.00.41.06.01.B4.0A.78.0A.00.00.00.00 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0A.17.00.0F.06.01.B4.0A.AA.0A.00.00.00.00 + this->status_config_ = statusFrame->inner.config; + this->status_config_valid_ = true; + this->status_config_updated_ = true; + + ESP_LOGV(TAG, "StatusFrameConfig Offset: %d", offset_code_to_decimal(this->status_config_.temp_offset)); + + return response; + } else if (header->message_type == STATUS_FRAME_DEVICES && header->message_length == sizeof(StatusFrameDevice)) { + ESP_LOGI(TAG, "StatusFrameDevice"); + // This message is special. I recieve one response per registered (at CP plus) device. + // Example: + // SID<---------PREAMBLE --------->|<---MSG HEAD --->| + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0C.0B.00.79.02.00.01.00.50.00.00.04.03.02.AD.10 - C4.03.02 0050.00 + // BB.00.1F.00.1E.00.00.22.FF.FF.FF.54.01.0C.0B.00.27.02.01.01.00.40.03.22.02.00.01.00.00 - H2.00.01 0340.22 + auto device = statusFrame->inner.device; + + this->init_recieved_ = esp_timer_get_time(); + + ESP_LOGV(TAG, "StatusFrameDevice %d/%d - %d.%02d.%02d %04x.%02x", device.device_id + 1, device.device_count, + device.software_revision[0], device.software_revision[1], device.software_revision[2], + device.hardware_revision_major, device.hardware_revision_minor); + + return response; + } else { + ESP_LOGW(TAG, "Unkown message type %02x", header->message_type); + } + (*return_len) = 1; + return nullptr; +} + +bool TrumaiNetBoxApp::has_update_to_submit_() { + if (this->init_requested_ == 0) { + this->init_requested_ = esp_timer_get_time(); + ESP_LOGV(TAG, "Requesting initial data."); + return true; + } else if (this->init_recieved_ == 0) { + auto init_wait_time = esp_timer_get_time() - this->init_requested_; + // it has been 5 seconds and i am still awaiting the init data. + if (init_wait_time > 1000 * 1000 * 5) { + ESP_LOGV(TAG, "Requesting initial data again."); + this->init_requested_ = esp_timer_get_time(); + return true; + } + } else if (this->update_status_heater_unsubmitted_ || this->update_status_timer_unsubmitted_ || + this->update_status_clock_unsubmitted_) { + if (this->update_time_ == 0) { + ESP_LOGV(TAG, "Notify CP Plus I got updates."); + this->update_time_ = esp_timer_get_time(); + return true; + } + auto update_wait_time = esp_timer_get_time() - this->update_time_; + if (update_wait_time > 1000 * 1000 * 5) { + ESP_LOGV(TAG, "Notify CP Plus again I still got updates."); + this->update_time_ = esp_timer_get_time(); + return true; + } + } + return false; +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/TrumaiNetBoxApp.h b/components/truma_inetbox/TrumaiNetBoxApp.h new file mode 100644 index 0000000..e3a2735 --- /dev/null +++ b/components/truma_inetbox/TrumaiNetBoxApp.h @@ -0,0 +1,461 @@ +#pragma once + +#include +#include "LinBusProtocol.h" +#include "esphome/core/automation.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome { +namespace truma_inetbox { + +#define LIN_PID_TRUMA_INET_BOX 0x18 +#define LIN_SID_RESPONSE 0x40 +#define LIN_SID_READ_STATE_BUFFER 0xBA +#define LIN_SID_FIll_STATE_BUFFFER 0xBB + +// Response to init are the following frames: +// - 2 * STATUS_FRAME_DEVICES +// - STATUS_FRAME_HEATER +// - STATUS_FRAME_TIMER +// - STAUTS_FRAME_CONFIG +// - STATUS_FRAME_CLOCK +#define STATUS_FRAME_RESPONSE_INIT_REQUEST 0x0A +#define STATUS_FRAME_DEVICES 0x0B +#define STATUS_FRAME_RESPONSE_ACK 0x0D +#define STATUS_FRAME_CLOCK 0x15 +#define STATUS_FRAME_CLOCK_RESPONSE (STATUS_FRAME_CLOCK - 1) +#define STAUTS_FRAME_CONFIG 0x17 +#define STAUTS_FRAME_CONFIG_RESPONSE (STAUTS_FRAME_CONFIG - 1) +// Why can I send 0x33 as response? +#define STATUS_FRAME_HEATER 0x33 +#define STATUS_FRAME_HEATER_RESPONSE (STATUS_FRAME_HEATER - 1) +// Error response +#define STATUS_FRAME_UNKOWN_34 0x34 +// Error response +#define STATUS_FRAME_UNKOWN_36 0x36 +#define STATUS_FRAME_TIMER 0x3D +#define STATUS_FRAME_TIMER_RESPONSE (STATUS_FRAME_TIMER - 1) +// Error response +#define STATUS_FRAME_UNKOWN_3E 0x3E + +enum class HeatingMode : u_int8_t { + HEATING_MODE_OFF = 0x0, + HEATING_MODE_ECO = 0x1, + HEATING_MODE_HIGH = 0xA, + HEATING_MODE_BOOST = 0xB, +}; + +enum class ElectricPowerLevel : u_int16_t { + ELECTRIC_POWER_LEVEL_0 = 0, + ELECTRIC_POWER_LEVEL_900 = 900, + ELECTRIC_POWER_LEVEL_1800 = 1800, +}; + +enum class TargetTemp : u_int16_t { + TARGET_TEMP_OFF = 0x0, + + // 40C + TARGET_TEMP_WATER_ECO = (40 + 273) * 10, + // 60C + TARGET_TEMP_WATER_HIGH = (60 + 273) * 10, + // 200C + TARGET_TEMP_WATER_BOOST = (200 + 273) * 10, + + TARGET_TEMP_ROOM_MIN = (5 + 273) * 10, + TARGET_TEMP_ROOM_05C = (5 + 273) * 10, + TARGET_TEMP_ROOM_06C = (6 + 273) * 10, + TARGET_TEMP_ROOM_07C = (7 + 273) * 10, + TARGET_TEMP_ROOM_08C = (8 + 273) * 10, + TARGET_TEMP_ROOM_09C = (9 + 273) * 10, + TARGET_TEMP_ROOM_10C = (10 + 273) * 10, + TARGET_TEMP_ROOM_11C = (11 + 273) * 10, + TARGET_TEMP_ROOM_12C = (12 + 273) * 10, + TARGET_TEMP_ROOM_13C = (13 + 273) * 10, + TARGET_TEMP_ROOM_14C = (14 + 273) * 10, + TARGET_TEMP_ROOM_15C = (15 + 273) * 10, + TARGET_TEMP_ROOM_16C = (16 + 273) * 10, + TARGET_TEMP_ROOM_17C = (17 + 273) * 10, + TARGET_TEMP_ROOM_18C = (18 + 273) * 10, + TARGET_TEMP_ROOM_19C = (19 + 273) * 10, + TARGET_TEMP_ROOM_20C = (20 + 273) * 10, + TARGET_TEMP_ROOM_21C = (21 + 273) * 10, + TARGET_TEMP_ROOM_22C = (22 + 273) * 10, + TARGET_TEMP_ROOM_23C = (23 + 273) * 10, + TARGET_TEMP_ROOM_24C = (24 + 273) * 10, + TARGET_TEMP_ROOM_25C = (25 + 273) * 10, + TARGET_TEMP_ROOM_26C = (26 + 273) * 10, + TARGET_TEMP_ROOM_27C = (27 + 273) * 10, + TARGET_TEMP_ROOM_28C = (28 + 273) * 10, + TARGET_TEMP_ROOM_29C = (29 + 273) * 10, + TARGET_TEMP_ROOM_30C = (30 + 273) * 10, + TARGET_TEMP_ROOM_MAX = (30 + 273) * 10, +}; + +enum class EnergyMix : u_int8_t { + ENERGY_MIX_NONE = 0b00, + ENERGY_MIX_GAS = 0b01, + ENERGY_MIX_ELECTRICITY = 0b10, + ENERGY_MIX_MIX = 0b11, +}; + +enum class OperatingStatus : u_int8_t { + OPERATING_STATUS_UNSET = 0x0, + OPERATING_STATUS_OFF = 0x0, + OPERATING_STATUS_WARNING = 0x1, + OPERATING_STATUS_START_OR_COOL_DOWN = 0x4, + // ON - Heater off + OPERATING_STATUS_ON_5 = 0x5, + OPERATING_STATUS_ON_6 = 0x6, + OPERATING_STATUS_ON_7 = 0x7, + OPERATING_STATUS_ON_8 = 0x8, + OPERATING_STATUS_ON_9 = 0x9, +}; + +enum class OperatingUnits : u_int8_t { + OPERATING_UNITS_CELSIUS = 0x0, + OPERATING_UNITS_FAHRENHEIT = 0x1, +}; + +enum class Language : u_int8_t { + LANGUAGE_GERMAN = 0x0, + LANGUAGE_ENGLISH = 0x1, + LANGUAGE_FRENCH = 0x2, + LANGUAGE_ITALY = 0x3, +}; + +enum class ResponseAckResult : u_int8_t { + RESPONSE_ACK_RESULT_OKAY = 0x0, + RESPONSE_ACK_RESULT_ERROR_INVALID_MSG = 0x2, + // The response status frame `message_type` is unkown. + RESPONSE_ACK_RESULT_ERROR_INVALID_ID = 0x3, +}; + +enum class TempOffset : u_int8_t { + TEMP_OFFSET_0_0C = (u_int8_t) ((-0.0f + 17) * 10), + TEMP_OFFSET_0_5C = (u_int8_t) ((-0.5f + 17) * 10), + TEMP_OFFSET_1_0C = (u_int8_t) ((-1.0f + 17) * 10), + TEMP_OFFSET_1_5C = (u_int8_t) ((-1.5f + 17) * 10), + TEMP_OFFSET_2_0C = (u_int8_t) ((-2.0f + 17) * 10), + TEMP_OFFSET_2_5C = (u_int8_t) ((-2.5f + 17) * 10), + TEMP_OFFSET_3_0C = (u_int8_t) ((-3.0f + 17) * 10), + TEMP_OFFSET_3_5C = (u_int8_t) ((-3.5f + 17) * 10), + TEMP_OFFSET_4_0C = (u_int8_t) ((-4.0f + 17) * 10), + TEMP_OFFSET_4_5C = (u_int8_t) ((-4.5f + 17) * 10), + TEMP_OFFSET_5_0C = (u_int8_t) ((-5.0f + 17) * 10), +}; + +enum class ClockMode : u_int8_t { + CLOCK_MODE_24H = 0x0, + CLOCK_MODE_12H = 0x1, +}; + +enum class TimerActive : u_int8_t { + TIMER_ACTIVE_ON = 0x1, + TIMER_ACTIVE_OFF = 0x0, +}; + +enum class ClockSource : u_int8_t { + // Set by user + CLOCK_SOURCE_MANUAL = 0x1, + // Set by message + CLOCK_SOURCE_PROG = 0x2, +}; + +struct StatusFrameHeader { // NOLINT(altera-struct-pack-align) + // sid + u_int8_t service_identifier; + u_int8_t header[10]; + u_int8_t header_2; + u_int8_t header_3; + // after checksum + u_int8_t message_length; + u_int8_t message_type; + u_int8_t command_counter; + u_int8_t checksum; +} __attribute__((packed)); + +// Length 20 (0x14) +struct StatusFrameHeater { // NOLINT(altera-struct-pack-align) + TargetTemp target_temp_room; + // Room + HeatingMode heating_mode; + u_int8_t heater_unkown_1; + ElectricPowerLevel el_power_level_a; + TargetTemp target_temp_water; + ElectricPowerLevel el_power_level_b; + EnergyMix energy_mix_a; + // Ignored by response + EnergyMix energy_mix_b; + u_int16_t current_temp_water; + u_int16_t current_temp_room; + OperatingStatus operating_status; + u_int16_t error_code; + u_int8_t heater_unkown_2; +} __attribute__((packed)); + +// Length 12 (0x0C) +struct StatusFrameHeaterResponse { // NOLINT(altera-struct-pack-align) + TargetTemp target_temp_room; + // Room + HeatingMode heating_mode; + u_int8_t recv_status_u3; + ElectricPowerLevel el_power_level_a; + TargetTemp target_temp_water; + ElectricPowerLevel el_power_level_b; + EnergyMix energy_mix_a; + // Ignored? + EnergyMix energy_mix_b; +} __attribute__((packed)); + +// Length 24 (0x18) +struct StatusFrameTimer { // NOLINT(altera-struct-pack-align) + TargetTemp timer_target_temp_room; + HeatingMode timer_heating_mode; + u_int8_t timer_unkown_1; + ElectricPowerLevel timer_el_power_level_a; + TargetTemp timer_target_temp_water; + ElectricPowerLevel timer_el_power_level_b; + EnergyMix timer_energy_mix_a; + EnergyMix timer_energy_mix_b; + // used by timer response message + u_int8_t unused[5]; + u_int8_t timer_unknown_3; + u_int8_t timer_unknown_4; + TimerActive timer_active; + u_int8_t timer_start_minutes; + u_int8_t timer_start_hours; + u_int8_t timer_stop_minutes; + u_int8_t timer_stop_hours; +} __attribute__((packed)); + +// Length 13 (0x0D) +struct StatusFrameTimerResponse { // NOLINT(altera-struct-pack-align) + TargetTemp timer_target_temp_room; + HeatingMode timer_heating_mode; + u_int8_t timer_unkown_1; + ElectricPowerLevel timer_el_power_level_a; + TargetTemp timer_target_temp_water; + ElectricPowerLevel timer_el_power_level_b; + EnergyMix timer_energy_mix_a; + EnergyMix timer_energy_mix_b; + // set by response message to active timer + TimerActive timer_resp_active; + // set by response message to active timer + u_int8_t timer_resp_start_minutes; + // set by response message to active timer + u_int8_t timer_resp_start_hours; + // set by response message to active timer + u_int8_t timer_resp_stop_minutes; + // set by response message to active timer + u_int8_t timer_resp_stop_hours; +} __attribute__((packed)); + +// Length 2 (0x02) +struct StatusFrameResponseAck { // NOLINT(altera-struct-pack-align) + ResponseAckResult error_code; + u_int8_t unkown; +} __attribute__((packed)); + +// Length 10 (0x0A) +struct StatusFrameClock { // NOLINT(altera-struct-pack-align) + u_int8_t clock_hour; + u_int8_t clock_minute; + u_int8_t clock_second; + // MUST be 0x1, 0x2, 0x3..? (lower than 0x9) + u_int8_t display_1; + // MUST be 0x1 + u_int8_t display_2; + u_int8_t display_3; + ClockMode clock_mode; + ClockSource clock_source; + u_int8_t display_4; + u_int8_t display_5; +} __attribute__((packed)); + +// Length 10 (0x0A) +struct StatusFrameConfig { // NOLINT(altera-struct-pack-align) + // 0x01 .. 0x0A + u_int8_t display_brightness; + Language language; + u_int8_t unkown_2; // 0xB4 + u_int8_t unkown_3; // 0x0A + TempOffset temp_offset; + u_int8_t unkown_5; // 0x0A + OperatingUnits temp_units; + u_int8_t unkown_6; + u_int8_t unkown_7; + u_int8_t unkown_8; +} __attribute__((packed)); + +// Length 12 (0x0C) +struct StatusFrameDevice { // NOLINT(altera-struct-pack-align) + u_int8_t device_count; + u_int8_t device_id; + // 0x01 - Maybe active or found + u_int8_t unkown_0; + // 0x00 + u_int8_t unkown_1; + u_int16_t hardware_revision_major; + u_int8_t hardware_revision_minor; + // `software_revision[0].software_revision[1].software_revision[2]` + u_int8_t software_revision[3]; + // 0xAD on CPplus + // 0x00 on Combi4 + u_int8_t unkown_2; + // 0x10 on CPplus + // 0x00 on Combi4 + u_int8_t unkown_3; + +} __attribute__((packed)); + +union StatusFrame { // NOLINT(altera-struct-pack-align) + u_int8_t raw[41]; + struct inner { // NOLINT(altera-struct-pack-align) + StatusFrameHeader genericHeader; + union { // NOLINT(altera-struct-pack-align) + StatusFrameHeater heater; + StatusFrameHeaterResponse heaterResponse; + StatusFrameTimer timer; + StatusFrameTimerResponse timerResponse; + StatusFrameResponseAck responseAck; + StatusFrameClock clock; + StatusFrameConfig config; + StatusFrameDevice device; + } __attribute__((packed)); + } inner; +} __attribute__((packed)); + +struct StatusFrameListener { + std::function on_heater_change; + std::function on_timer_change; + std::function on_clock_change; + std::function on_config_change; +}; + +class TrumaiNetBoxApp : public LinBusProtocol { + public: + TrumaiNetBoxApp(u_int8_t expected_listener_count); + + void update() override; + + const std::array lin_identifier() override; + void lin_reset_device() override; + + void set_time(time::RealTimeClock *time) { time_ = time; } + time::RealTimeClock *get_time() const { return time_; } + + bool get_status_heater_valid() { return this->status_heater_valid_; } + const StatusFrameHeater *get_status_heater() { return &this->status_heater_; } + void register_listener(const std::function &func); + + bool get_status_timer_valid() { return this->status_timer_valid_; } + const StatusFrameTimer *get_status_timer() { return &this->status_timer_; } + void register_listener(const std::function &func); + + bool get_status_clock_valid() { return this->status_clock_valid_; } + const StatusFrameClock *get_status_clock() { return &this->status_clock_; } + void register_listener(const std::function &func); + + bool get_status_config_valid() { return this->status_config_valid_; } + const StatusFrameConfig *get_status_config() { return &this->status_config_; } + void register_listener(const std::function &func); + + bool truma_heater_can_update() { return this->status_heater_valid_; } + StatusFrameHeaterResponse *update_heater_prepare(); + void update_heater_submit() { this->update_status_heater_unsubmitted_ = true; } + + bool truma_timer_can_update() { return this->status_timer_valid_; } + StatusFrameTimerResponse *update_timer_prepare(); + void update_timer_submit() { this->update_status_timer_unsubmitted_ = true; } + + bool truma_clock_can_update() { return this->status_clock_valid_; } + void update_clock_submit() { this->update_status_clock_unsubmitted_ = true; } + + int64_t get_last_cp_plus_request() { return this->device_registered_; } + + // Automation + void add_on_heater_message_callback(std::function callback) { + this->state_heater_callback_.add(std::move(callback)); + } + bool action_heater_room(u_int8_t temperature, HeatingMode mode = HeatingMode::HEATING_MODE_OFF); + bool action_heater_water(u_int8_t temperature); + bool action_heater_water(TargetTemp temperature); + bool action_heater_electric_power_level(u_int16_t value); + bool action_heater_energy_mix(EnergyMix energy_mix, + ElectricPowerLevel el_power_level = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0); + bool action_timer_disable(); + bool action_timer_activate(u_int16_t start, u_int16_t stop, u_int8_t room_temperature, + HeatingMode mode = HeatingMode::HEATING_MODE_OFF, u_int8_t water_temperature = 0, + EnergyMix energy_mix = EnergyMix::ENERGY_MIX_NONE, + ElectricPowerLevel el_power_level = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0); + bool action_read_time(); + bool action_write_time(); + + protected: + time::RealTimeClock *time_; + + // Truma CP Plus needs init (reset). This device is not registered. + int64_t device_registered_ = 0; + int64_t init_requested_ = 0; + int64_t init_recieved_ = 0; + u_int8_t message_counter = 1; + + std::vector listeners_heater_; + CallbackManager state_heater_callback_{}; + + bool status_heater_valid_ = false; + // Value has changed notify listeners. + bool status_heater_updated_ = false; + StatusFrameHeater status_heater_; + + bool status_timer_valid_ = false; + // Value has changed notify listeners. + bool status_timer_updated_ = false; + StatusFrameTimer status_timer_; + + bool status_clock_valid_ = false; + // Value has changed notify listeners. + bool status_clock_updated_ = false; + StatusFrameClock status_clock_; + + bool status_config_valid_ = false; + // Value has changed notify listeners. + bool status_config_updated_ = false; + StatusFrameConfig status_config_; + + // last time CP plus was informed I got an update msg. + int64_t update_time_ = 0; + // Prepared means `update_status_heater_` was copied from `status_heater_`. + bool update_status_heater_prepared_ = false; + // Prepared means an update is already awating fetch from CP plus. + bool update_status_heater_unsubmitted_ = false; + // I have submitted my update request to CP plus, but I have not recieved an update with new heater values from CP + // plus. + bool update_status_heater_stale_ = false; + StatusFrameHeaterResponse update_status_heater_; + + // Prepared means `update_status_timer_` was copied from `status_timer_`. + bool update_status_timer_prepared_ = false; + // Prepared means an update is already awating fetch from CP plus. + bool update_status_timer_unsubmitted_ = false; + // I have submitted my update request to CP plus, but I have not recieved an update with new timer values from CP + // plus. + bool update_status_timer_stale_ = false; + StatusFrameTimerResponse update_status_timer_; + + // The behaviour of `update_status_clock_unsubmitted_` is special. + // Just an update is marked. The actual package is prepared when CP Plus asks for the data in the + // `lin_multiframe_recieved` method. + bool update_status_clock_unsubmitted_ = false; + + bool answer_lin_order_(const u_int8_t pid) override; + + bool lin_read_field_by_identifier_(u_int8_t identifier, std::array *response) override; + const u_int8_t *lin_multiframe_recieved(const u_int8_t *message, const u_int8_t message_len, + u_int8_t *return_len) override; + + bool has_update_to_submit_(); +}; + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/TrumaiNetBoxApp_automation.cpp b/components/truma_inetbox/TrumaiNetBoxApp_automation.cpp new file mode 100644 index 0000000..35f6061 --- /dev/null +++ b/components/truma_inetbox/TrumaiNetBoxApp_automation.cpp @@ -0,0 +1,250 @@ +#include "TrumaiNetBoxApp.h" +#include "TrumaStatusFrame.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.TrumaiNetBoxApp"; + +bool TrumaiNetBoxApp::action_heater_room(u_int8_t temperature, HeatingMode mode) { + if (!this->truma_heater_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto heater = this->update_heater_prepare(); + + heater->target_temp_room = decimal_to_room_temp(temperature); + + // Ensure `heating_mode` and `energy_mix_a` is set. + if (heater->target_temp_room == TargetTemp::TARGET_TEMP_OFF) { + heater->heating_mode = HeatingMode::HEATING_MODE_OFF; + } else { + // If parameter `mode` contains a valid Heating mode use it or else use `ECO`. + if (mode == HeatingMode::HEATING_MODE_ECO || mode == HeatingMode::HEATING_MODE_HIGH || + mode == HeatingMode::HEATING_MODE_BOOST) { + heater->heating_mode = mode; + } else if (heater->heating_mode == HeatingMode::HEATING_MODE_OFF) { + heater->heating_mode = HeatingMode::HEATING_MODE_ECO; + } + } + if (heater->energy_mix_a == EnergyMix::ENERGY_MIX_NONE) { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + this->update_heater_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_heater_water(u_int8_t temperature) { + if (!this->truma_heater_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto heater = this->update_heater_prepare(); + + heater->target_temp_water = deciaml_to_water_temp(temperature); + + // Ensure `energy_mix_a` is set. + if (heater->target_temp_water != TargetTemp::TARGET_TEMP_OFF && heater->energy_mix_a == EnergyMix::ENERGY_MIX_NONE) { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + this->update_heater_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_heater_water(TargetTemp temperature) { + if (!this->truma_heater_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto heater = this->update_heater_prepare(); + + // If parameter `temperature` contains a valid mode use it or else use `OFF`. + if (temperature == TargetTemp::TARGET_TEMP_WATER_ECO || temperature == TargetTemp::TARGET_TEMP_WATER_HIGH || + temperature == TargetTemp::TARGET_TEMP_WATER_BOOST) { + heater->target_temp_water = temperature; + } else { + heater->target_temp_water = TargetTemp::TARGET_TEMP_OFF; + } + + // Ensure `energy_mix_a` is set. + if (heater->target_temp_water != TargetTemp::TARGET_TEMP_OFF && heater->energy_mix_a == EnergyMix::ENERGY_MIX_NONE) { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + this->update_heater_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_heater_electric_power_level(u_int16_t value) { + if (!this->truma_heater_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto heater = this->update_heater_prepare(); + + heater->el_power_level_a = decimal_to_el_power_level(value); + if (heater->el_power_level_a != ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0) { + if (heater->energy_mix_a != EnergyMix::ENERGY_MIX_MIX && + heater->energy_mix_a != EnergyMix::ENERGY_MIX_ELECTRICITY) { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_MIX; + } + } else { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + this->update_heater_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_heater_energy_mix(EnergyMix energy_mix, ElectricPowerLevel el_power_level) { + if (!this->truma_heater_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto heater = this->update_heater_prepare(); + + // If parameter `el_power_level` contains a valid mode use it. + if (el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0 || + el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900 || + el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_1800) { + heater->el_power_level_a = el_power_level; + } + + if (energy_mix == EnergyMix::ENERGY_MIX_GAS) { + heater->energy_mix_a = energy_mix; + heater->el_power_level_a = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0; + } else if (energy_mix == EnergyMix::ENERGY_MIX_MIX || energy_mix == EnergyMix::ENERGY_MIX_ELECTRICITY) { + heater->energy_mix_a = energy_mix; + // Electric energy is requested by user without a power level. Set it to minimum. + if (heater->el_power_level_a == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0) { + heater->el_power_level_a = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900; + } + } + + // This last check is reached if invalid `energy_mix` parameter was submitted. + if (heater->el_power_level_a != ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0) { + if (heater->energy_mix_a != EnergyMix::ENERGY_MIX_MIX && + heater->energy_mix_a != EnergyMix::ENERGY_MIX_ELECTRICITY) { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_MIX; + } + } else { + heater->energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + this->update_heater_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_timer_disable() { + if (!this->truma_timer_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto timer = this->update_timer_prepare(); + + timer->timer_resp_active = TimerActive::TIMER_ACTIVE_OFF; + + this->update_timer_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_timer_activate(u_int16_t start, u_int16_t stop, u_int8_t room_temperature, + HeatingMode mode, u_int8_t water_temperature, EnergyMix energy_mix, + ElectricPowerLevel el_power_level) { + if (!this->truma_timer_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + if (start > 1440 || stop > 1440) { + ESP_LOGW(TAG, "Invalid values start/stop submitted."); + return false; + } + + auto timer = this->update_timer_prepare(); + + timer->timer_resp_active = TimerActive::TIMER_ACTIVE_ON; + timer->timer_resp_start_hours = start / 60; + timer->timer_resp_start_minutes = start % 60; + timer->timer_resp_stop_hours = stop / 60; + timer->timer_resp_stop_minutes = stop % 60; + timer->timer_target_temp_room = decimal_to_room_temp(room_temperature); + + // Ensure `timer_heating_mode` and `timer_energy_mix_a` is set. + if (timer->timer_target_temp_room == TargetTemp::TARGET_TEMP_OFF) { + timer->timer_heating_mode = HeatingMode::HEATING_MODE_OFF; + } else { + // If parameter `mode` contains a valid Heating mode use it or else use `ECO`. + if (mode == HeatingMode::HEATING_MODE_ECO || mode == HeatingMode::HEATING_MODE_HIGH || + mode == HeatingMode::HEATING_MODE_BOOST) { + timer->timer_heating_mode = mode; + } else if (timer->timer_heating_mode == HeatingMode::HEATING_MODE_OFF) { + timer->timer_heating_mode = HeatingMode::HEATING_MODE_ECO; + } + } + + timer->timer_target_temp_water = deciaml_to_water_temp(water_temperature); + + // If parameter `el_power_level` contains a valid mode use it. + if (el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0 || + el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900 || + el_power_level == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_1800) { + timer->timer_el_power_level_a = el_power_level; + } + + // Ensure `timer_energy_mix_a` is set + if (timer->timer_energy_mix_a == EnergyMix::ENERGY_MIX_NONE) { + timer->timer_energy_mix_a = EnergyMix::ENERGY_MIX_GAS; + } + + // User has supplied a `energy_mix` + if (energy_mix == EnergyMix::ENERGY_MIX_GAS) { + timer->timer_energy_mix_a = energy_mix; + timer->timer_el_power_level_a = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0; + } else if (energy_mix == EnergyMix::ENERGY_MIX_MIX || energy_mix == EnergyMix::ENERGY_MIX_ELECTRICITY) { + timer->timer_energy_mix_a = energy_mix; + // Electric energy is requested by user without a power level. Set it to minimum. + if (timer->timer_el_power_level_a == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0) { + timer->timer_el_power_level_a = ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900; + } + } + + this->update_timer_submit(); + return true; +} + +bool TrumaiNetBoxApp::action_read_time() { + // int ret = settimeofday(&timev, &tz); + // if (ret == EINVAL) { + // // Some ESP8266 frameworks abort when timezone parameter is not NULL + // // while ESP32 expects it not to be NULL + // ret = settimeofday(&timev, nullptr); + // } + return false; +} + +bool TrumaiNetBoxApp::action_write_time() { + if (!this->truma_clock_can_update()) { + ESP_LOGW(TAG, "Cannot update Truma."); + return false; + } + auto now = this->time_->now(); + if (!now.is_valid()) { + ESP_LOGW(TAG, "Invalid system time, not syncing to CP Plus."); + return false; + } + + // The behaviour of this method is special. + // Just an update is marked. The actual package is prepared when CP Plus asks for the data in the + // `lin_multiframe_recieved` method. + + this->update_clock_submit(); + return true; +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/__init__.py b/components/truma_inetbox/__init__.py new file mode 100644 index 0000000..fab3bda --- /dev/null +++ b/components/truma_inetbox/__init__.py @@ -0,0 +1,366 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins, automation +from esphome.components import sensor, uart, time +from esphome.const import ( + CONF_ID, + CONF_CS_PIN, + CONF_TEMPERATURE, + CONF_ON_MESSAGE, + CONF_TRIGGER_ID, + CONF_STOP, + CONF_TIME_ID, +) +from .entity_helpers import count_id_usage + +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@Fabian-Schmidt"] + +CONF_TRUMA_INETBOX_ID = "truma_inetbox_id" +CONF_LIN_CHECKSUM = "lin_checksum" +CONF_FAULT_PIN = "fault_pin" +CONF_OBSERVER_MODE = "observer_mode" +CONF_NUMBER_OF_CHILDREN = "number_of_children" +CONF_ON_HEATER_MESSAGE = "on_heater_message" + +truma_inetbox_ns = cg.esphome_ns.namespace("truma_inetbox") +StatusFrameHeater = truma_inetbox_ns.struct("StatusFrameHeater") +StatusFrameHeaterConstPtr = StatusFrameHeater.operator("ptr").operator("const") +TrumaINetBoxApp = truma_inetbox_ns.class_( + "TrumaiNetBoxApp", cg.PollingComponent, uart.UARTDevice +) +TrumaiNetBoxAppHeaterMessageTrigger = truma_inetbox_ns.class_( + "TrumaiNetBoxAppHeaterMessageTrigger", + automation.Trigger.template(cg.int_, cg.const_char_ptr, cg.const_char_ptr), +) + +# `LIN_CHECKSUM` is a enum class and not a namespace but it works. +LIN_CHECKSUM_dummy_ns = truma_inetbox_ns.namespace("LIN_CHECKSUM") + +CONF_SUPPORTED_LIN_CHECKSUM = { + "VERSION_1": LIN_CHECKSUM_dummy_ns.LIN_CHECKSUM_VERSION_1, + "VERSION_2": LIN_CHECKSUM_dummy_ns.LIN_CHECKSUM_VERSION_2, +} + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TrumaINetBoxApp), + cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_LIN_CHECKSUM, "VERSION_2"): cv.enum(CONF_SUPPORTED_LIN_CHECKSUM, upper=True), + cv.Optional(CONF_CS_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_FAULT_PIN): pins.gpio_input_pin_schema, + cv.Optional(CONF_OBSERVER_MODE): cv.boolean, + cv.Optional(CONF_ON_HEATER_MESSAGE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TrumaiNetBoxAppHeaterMessageTrigger), + } + ), + } + ) + # Polling is for presenting data to sensors. + # Reading and communication is done in a seperate thread/core. + .extend(cv.polling_component_schema("500ms")) + .extend(uart.UART_DEVICE_SCHEMA), + cv.only_with_arduino, + cv.only_on(["esp32"]), +) +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + # TODO: Validate 2 Stop bits are configured. + "truma_inetbox", baud_rate=9600, require_tx=True, require_rx=True + ), + count_id_usage(CONF_NUMBER_OF_CHILDREN, [ + CONF_TRUMA_INETBOX_ID, CONF_ID], TrumaINetBoxApp), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID], config[CONF_NUMBER_OF_CHILDREN]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + time_ = await cg.get_variable(config[CONF_TIME_ID]) + cg.add(var.set_time(time_)) + + if CONF_LIN_CHECKSUM in config: + cg.add(var.set_lin_checksum( + CONF_SUPPORTED_LIN_CHECKSUM[config[CONF_LIN_CHECKSUM]])) + + if CONF_CS_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_CS_PIN]) + cg.add(var.set_cs_pin(pin)) + + if CONF_FAULT_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_FAULT_PIN]) + cg.add(var.set_fault_pin(pin)) + + if CONF_OBSERVER_MODE in config: + cg.add(var.set_observer_mode(config[CONF_OBSERVER_MODE])) + + for conf in config.get(CONF_ON_HEATER_MESSAGE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, [(StatusFrameHeaterConstPtr, "message")], conf + ) + + +# AUTOMATION + +CONF_ENERGY_MIX = "energy_mix" +CONF_ELECTRIC_POWER_LEVEL = "electric_power_level" +CONF_HEATING_MODE = "heating_mode" +CONF_WATT = "watt" +CONF_START = "start" +CONF_ROOM_TEMPERATURE = "room_temperature" +CONF_WATER_TEMPERATURE = "water_temperature" + +HeaterRoomTempAction = truma_inetbox_ns.class_( + "HeaterRoomTempAction", automation.Action) +HeaterWaterTempAction = truma_inetbox_ns.class_( + "HeaterWaterTempAction", automation.Action) +HeaterWaterTempEnumAction = truma_inetbox_ns.class_( + "HeaterWaterTempEnumAction", automation.Action) +HeaterElecPowerLevelAction = truma_inetbox_ns.class_( + "HeaterElecPowerLevelAction", automation.Action) +HeaterEnergyMixAction = truma_inetbox_ns.class_( + "HeaterEnergyMixAction", automation.Action) +TimerDisableAction = truma_inetbox_ns.class_( + "TimerDisableAction", automation.Action) +TimerActivateAction = truma_inetbox_ns.class_( + "TimerActivateAction", automation.Action) +WriteTimeAction = truma_inetbox_ns.class_("WriteTimeAction", automation.Action) + +# `EnergyMix` is a enum class and not a namespace but it works. +EnergyMix_dummy_ns = truma_inetbox_ns.namespace("EnergyMix") + +CONF_SUPPORTED_ENERGY_MIX = { + "NONE": EnergyMix_dummy_ns.ENERGY_MIX_NONE, + "GAS": EnergyMix_dummy_ns.ENERGY_MIX_GAS, + "ELECTRICITY": EnergyMix_dummy_ns.ENERGY_MIX_ELECTRICITY, + "MIX": EnergyMix_dummy_ns.ENERGY_MIX_MIX, +} + +# `ElectricPowerLevel` is a enum class and not a namespace but it works. +ElectricPowerLevel_dummy_ns = truma_inetbox_ns.namespace("ElectricPowerLevel") + +CONF_SUPPORTED_ELECTRIC_POWER_LEVEL = { + "0": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_0, + "0W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_0, + "0 W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_0, + "900": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_900, + "900W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_900, + "900 W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_900, + "1800": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1800W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1800 W": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1.8kW": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1,8kW": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1.8 kW": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, + "1,8 kW": ElectricPowerLevel_dummy_ns.ELECTRIC_POWER_LEVEL_1800, +} + +# `HeatingMode` is a enum class and not a namespace but it works. +HeatingMode_dummy_ns = truma_inetbox_ns.namespace("HeatingMode") + +CONF_SUPPORTED_HEATING_MODE = { + "OFF": HeatingMode_dummy_ns.HEATING_MODE_OFF, + "ECO": HeatingMode_dummy_ns.HEATING_MODE_ECO, + "HIGH": HeatingMode_dummy_ns.HEATING_MODE_HIGH, + "BOOST": HeatingMode_dummy_ns.HEATING_MODE_BOOST, +} + +# `TargetTemp` is a enum class and not a namespace but it works. +TargetTemp_dummy_ns = truma_inetbox_ns.namespace("TargetTemp") + +CONF_SUPPORTED_WATER_TEMPERATURE = { + "OFF": TargetTemp_dummy_ns.TARGET_TEMP_OFF, + "ECO": TargetTemp_dummy_ns.TARGET_TEMP_WATER_ECO, + "HIGH": TargetTemp_dummy_ns.TARGET_TEMP_WATER_HIGH, + "BOOST": TargetTemp_dummy_ns.TARGET_TEMP_WATER_BOOST, +} + + +@automation.register_action( + "truma_inetbox.heater.set_target_room_temperature", + HeaterRoomTempAction, + automation.maybe_conf( + CONF_TEMPERATURE, + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TEMPERATURE): cv.templatable(cv.int_range(min=0, max=30)), + cv.Optional(CONF_HEATING_MODE, "OFF"): cv.templatable(cv.enum(CONF_SUPPORTED_HEATING_MODE, upper=True)), + } + ), +) +async def truma_inetbox_heater_set_target_room_temperature_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.uint8) + cg.add(var.set_temperature(template_)) + + template_ = await cg.templatable(config[CONF_HEATING_MODE], args, cg.uint8) + cg.add(var.set_heating_mode(template_)) + + return var + + +@automation.register_action( + "truma_inetbox.heater.set_target_water_temperature", + HeaterWaterTempAction, + automation.maybe_conf( + CONF_TEMPERATURE, + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TEMPERATURE): cv.templatable(cv.int_range(min=0, max=80)), + } + ), +) +async def truma_inetbox_heater_set_target_water_temperature_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.uint8) + cg.add(var.set_temperature(template_)) + + return var + + +@automation.register_action( + "truma_inetbox.heater.set_target_water_temperature_enum", + HeaterWaterTempEnumAction, + automation.maybe_conf( + CONF_TEMPERATURE, + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TEMPERATURE): cv.templatable(cv.enum(CONF_SUPPORTED_WATER_TEMPERATURE, upper=True)) + } + ), +) +async def truma_inetbox_heater_set_target_water_temperature_enum_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.uint16) + cg.add(var.set_temperature(template_)) + + return var + + +@automation.register_action( + "truma_inetbox.heater.set_electric_power_level", + HeaterElecPowerLevelAction, + automation.maybe_conf( + CONF_WATT, + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_WATT): cv.templatable(cv.int_range(min=0, max=1800)) + } + ), +) +async def truma_inetbox_heater_set_electric_power_level_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_WATT], args, cg.uint16) + cg.add(var.set_watt(template_)) + + return var + + +@automation.register_action( + "truma_inetbox.heater.set_energy_mix", + HeaterEnergyMixAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_ENERGY_MIX): cv.templatable(cv.enum(CONF_SUPPORTED_ENERGY_MIX, upper=True)), + cv.Optional(CONF_WATT, 0): cv.templatable(cv.enum(CONF_SUPPORTED_ELECTRIC_POWER_LEVEL, upper=True)), + } + ), +) +async def truma_inetbox_heater_set_energy_mix_level_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_ENERGY_MIX], args, cg.uint8) + cg.add(var.set_energy_mix(template_)) + + template_ = await cg.templatable(config[CONF_WATT], args, cg.uint16) + cg.add(var.set_watt(template_)) + + return var + + +@automation.register_action( + "truma_inetbox.timer.disable", + TimerDisableAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + } + ), +) +async def truma_inetbox_timer_disable_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "truma_inetbox.timer.activate", + TimerActivateAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_START): cv.templatable(cv.int_range(min=0, max=1440)), + cv.Required(CONF_STOP): cv.templatable(cv.int_range(min=0, max=1440)), + cv.Required(CONF_ROOM_TEMPERATURE): cv.templatable(cv.int_range(min=0, max=30)), + cv.Optional(CONF_HEATING_MODE, "OFF"): cv.templatable(cv.enum(CONF_SUPPORTED_HEATING_MODE, upper=True)), + cv.Optional(CONF_WATER_TEMPERATURE, 0): cv.templatable(cv.int_range(min=0, max=80)), + cv.Optional(CONF_ENERGY_MIX, "NONE"): cv.templatable(cv.enum(CONF_SUPPORTED_ENERGY_MIX, upper=True)), + cv.Optional(CONF_WATT, 0): cv.templatable(cv.enum(CONF_SUPPORTED_ELECTRIC_POWER_LEVEL, upper=True)), + + } + ), +) +async def truma_inetbox_timer_activate_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_START], args, cg.uint16) + cg.add(var.set_start(template_)) + + template_ = await cg.templatable(config[CONF_STOP], args, cg.uint16) + cg.add(var.set_stop(template_)) + + template_ = await cg.templatable(config[CONF_ROOM_TEMPERATURE], args, cg.uint8) + cg.add(var.set_room_temperature(template_)) + + template_ = await cg.templatable(config[CONF_HEATING_MODE], args, cg.uint8) + cg.add(var.set_heating_mode(template_)) + + template_ = await cg.templatable(config[CONF_WATER_TEMPERATURE], args, cg.uint8) + cg.add(var.set_water_temperature(template_)) + + template_ = await cg.templatable(config[CONF_ENERGY_MIX], args, cg.uint8) + cg.add(var.set_energy_mix(template_)) + + template_ = await cg.templatable(config[CONF_WATT], args, cg.uint16) + cg.add(var.set_watt(template_)) + return var + + +@automation.register_action( + "truma_inetbox.clock.set", + WriteTimeAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(TrumaINetBoxApp), + } + ), +) +async def truma_inetbox_clock_set_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/components/truma_inetbox/automation.h b/components/truma_inetbox/automation.h new file mode 100644 index 0000000..ae2108b --- /dev/null +++ b/components/truma_inetbox/automation.h @@ -0,0 +1,91 @@ +#pragma once + +#include "esphome/core/component.h" +#include "TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +template class HeaterRoomTempAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(u_int8_t, temperature) + TEMPLATABLE_VALUE(HeatingMode, heating_mode) + + void play(Ts... x) override { + this->parent_->action_heater_room(this->temperature_.value_or(x..., 0), + this->heating_mode_.value_or(x..., HeatingMode::HEATING_MODE_OFF)); + } +}; + +template class HeaterWaterTempAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(u_int8_t, temperature) + + void play(Ts... x) override { this->parent_->action_heater_water(this->temperature_.value_or(x..., 0)); } +}; + +template class HeaterWaterTempEnumAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(TargetTemp, temperature) + + void play(Ts... x) override { + this->parent_->action_heater_water(this->temperature_.value_or(x..., TargetTemp::TARGET_TEMP_OFF)); + } +}; + +template class HeaterElecPowerLevelAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(u_int16_t, watt) + + void play(Ts... x) override { this->parent_->action_heater_electric_power_level(this->watt_.value_or(x..., 0)); } +}; + +template class HeaterEnergyMixAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(EnergyMix, energy_mix) + TEMPLATABLE_VALUE(ElectricPowerLevel, watt) + + void play(Ts... x) override { + this->parent_->action_heater_energy_mix(this->energy_mix_.value_or(x..., EnergyMix::ENERGY_MIX_GAS), + this->watt_.value_or(x..., ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0)); + } +}; + +template class TimerDisableAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->action_timer_disable(); } +}; + +template class TimerActivateAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(u_int16_t, start) + TEMPLATABLE_VALUE(u_int16_t, stop) + TEMPLATABLE_VALUE(u_int8_t, room_temperature) + TEMPLATABLE_VALUE(HeatingMode, heating_mode) + TEMPLATABLE_VALUE(u_int8_t, water_temperature) + TEMPLATABLE_VALUE(EnergyMix, energy_mix) + TEMPLATABLE_VALUE(ElectricPowerLevel, watt) + + void play(Ts... x) override { + this->parent_->action_timer_activate( + this->start_.value(x...), this->stop_.value(x...), this->room_temperature_.value(x...), + this->heating_mode_.value_or(x..., HeatingMode::HEATING_MODE_OFF), this->water_temperature_.value_or(x..., 0), + this->energy_mix_.value_or(x..., EnergyMix::ENERGY_MIX_NONE), + this->watt_.value_or(x..., ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0)); + } +}; + +template class WriteTimeAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->action_write_time(); } +}; + +class TrumaiNetBoxAppHeaterMessageTrigger : public Trigger { + public: + explicit TrumaiNetBoxAppHeaterMessageTrigger(TrumaiNetBoxApp *parent) { + parent->add_on_heater_message_callback([this](const StatusFrameHeater *message) { this->trigger(message); }); + } +}; + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.cpp b/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.cpp new file mode 100644 index 0000000..096a872 --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.cpp @@ -0,0 +1,21 @@ +#include "TrumaCpPlusBinarySensor.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.binary_sensor"; + +void TrumaCpPlusBinarySensor::update() { + if (this->parent_->get_lin_bus_fault() || (this->parent_->get_last_cp_plus_request() == 0)) { + this->publish_state(false); + return; + } + auto timeout = this->parent_->get_last_cp_plus_request() + 30 * 1000 * 1000 /* 30 seconds*/; + this->publish_state(esp_timer_get_time() < timeout); +} + +void TrumaCpPlusBinarySensor::dump_config() { ESP_LOGCONFIG("", "Truma CP Plus Binary Sensor"); } +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.h b/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.h new file mode 100644 index 0000000..edd13bb --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaCpPlusBinarySensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +class TrumaCpPlusBinarySensor : public PollingComponent, + public binary_sensor::BinarySensor, + public Parented { + public: + void update() override; + void dump_config() override; + + protected: + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.cpp b/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.cpp new file mode 100644 index 0000000..30f56ea --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.cpp @@ -0,0 +1,42 @@ +#include "TrumaHeaterBinarySensor.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.binary_sensor"; + +void TrumaHeaterBinarySensor::setup() { + this->parent_->register_listener([this](const StatusFrameHeater *status_heater) { + switch (this->type_) { + case TRUMA_BINARY_SENSOR_TYPE::HEATER_ROOM: + this->publish_state(status_heater->target_temp_room != TargetTemp::TARGET_TEMP_OFF); + break; + case TRUMA_BINARY_SENSOR_TYPE::HEATER_WATER: + this->publish_state(status_heater->target_temp_water != TargetTemp::TARGET_TEMP_OFF); + break; + case TRUMA_BINARY_SENSOR_TYPE::HEATER_GAS: + this->publish_state(status_heater->energy_mix_a == EnergyMix::ENERGY_MIX_GAS); + break; + case TRUMA_BINARY_SENSOR_TYPE::HEATER_MIX_1: + this->publish_state(status_heater->energy_mix_a == EnergyMix::ENERGY_MIX_MIX && + status_heater->el_power_level_a == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900); + break; + case TRUMA_BINARY_SENSOR_TYPE::HEATER_MIX_2: + this->publish_state(status_heater->energy_mix_a == EnergyMix::ENERGY_MIX_MIX && + status_heater->el_power_level_a == ElectricPowerLevel::ELECTRIC_POWER_LEVEL_1800); + break; + case TRUMA_BINARY_SENSOR_TYPE::HEATER_ELECTRICITY: + this->publish_state(status_heater->energy_mix_a == EnergyMix::ENERGY_MIX_ELECTRICITY); + break; + } + }); +} + +void TrumaHeaterBinarySensor::dump_config() { + ESP_LOGCONFIG("", "Truma Heater Binary Sensor"); + ESP_LOGCONFIG(TAG, "Type %u", this->type_); +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.h b/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.h new file mode 100644 index 0000000..b9d2f59 --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaHeaterBinarySensor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" +#include "enum.h" + +namespace esphome { +namespace truma_inetbox { + +class TrumaHeaterBinarySensor : public Component, public binary_sensor::BinarySensor, public Parented { + public: + void setup() override; + void dump_config() override; + + void set_type(TRUMA_BINARY_SENSOR_TYPE val) { this->type_ = val; } + + protected: + TRUMA_BINARY_SENSOR_TYPE type_; + + private: +}; + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.cpp b/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.cpp new file mode 100644 index 0000000..749261a --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.cpp @@ -0,0 +1,31 @@ +#include "TrumaTimerBinarySensor.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.binary_sensor"; + +void TrumaTimerBinarySensor::setup() { + this->parent_->register_listener([this](const StatusFrameTimer *status_timer) { + switch (this->type_) { + case TRUMA_BINARY_SENSOR_TYPE::TIMER_ACTIVE: + this->publish_state(status_timer->timer_active == TimerActive::TIMER_ACTIVE_ON); + break; + case TRUMA_BINARY_SENSOR_TYPE::TIMER_ROOM: + this->publish_state(status_timer->timer_target_temp_room != TargetTemp::TARGET_TEMP_OFF); + break; + case TRUMA_BINARY_SENSOR_TYPE::TIMER_WATER: + this->publish_state(status_timer->timer_target_temp_water != TargetTemp::TARGET_TEMP_OFF); + break; + } + }); +} + +void TrumaTimerBinarySensor::dump_config() { + ESP_LOGCONFIG("", "Truma Timer Binary Sensor"); + ESP_LOGCONFIG(TAG, "Type %u", this->type_); +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.h b/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.h new file mode 100644 index 0000000..f883cb3 --- /dev/null +++ b/components/truma_inetbox/binary_sensor/TrumaTimerBinarySensor.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" +#include "enum.h" + +namespace esphome { +namespace truma_inetbox { + +class TrumaTimerBinarySensor : public Component, public binary_sensor::BinarySensor, public Parented { + public: + void setup() override; + void dump_config() override; + + void set_type(TRUMA_BINARY_SENSOR_TYPE val) { this->type_ = val; } + + protected: + TRUMA_BINARY_SENSOR_TYPE type_; + + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/binary_sensor/__init__.py b/components/truma_inetbox/binary_sensor/__init__.py new file mode 100644 index 0000000..f157194 --- /dev/null +++ b/components/truma_inetbox/binary_sensor/__init__.py @@ -0,0 +1,70 @@ +from esphome.components import binary_sensor +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_UPDATE_INTERVAL +) +from .. import truma_inetbox_ns, CONF_TRUMA_INETBOX_ID, TrumaINetBoxApp + +DEPENDENCIES = ["truma_inetbox"] +CODEOWNERS = ["@Fabian-Schmidt"] + +TrumaSensor = truma_inetbox_ns.class_( + "TrumaBinarySensor", binary_sensor.BinarySensor, cg.Component) + +# `TRUMA_BINARY_SENSOR_TYPE` is a enum class and not a namespace but it works. +TRUMA_BINARY_SENSOR_TYPE_dummy_ns = truma_inetbox_ns.namespace( + "TRUMA_BINARY_SENSOR_TYPE") + +# 0 - C++ class +# 1 - C++ enum +CONF_SUPPORTED_TYPE = { + # TrumaCpPlusBinarySensor + "CP_PLUS_CONNECTED": (truma_inetbox_ns.class_("TrumaCpPlusBinarySensor", binary_sensor.BinarySensor, cg.PollingComponent), None), + # TrumaHeaterBinarySensor + "HEATER_ROOM": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_ROOM), + "HEATER_WATER": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_WATER), + "HEATER_GAS": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_GAS), + "HEATER_MIX_1": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_MIX_1), + "HEATER_MIX_2": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_MIX_2), + "HEATER_ELECTRICITY": (truma_inetbox_ns.class_("TrumaHeaterBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.HEATER_ELECTRICITY), + # TrumaTimerBinarySensor + "TIMER_ACTIVE": (truma_inetbox_ns.class_("TrumaTimerBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.TIMER_ACTIVE), + "TIMER_ROOM": (truma_inetbox_ns.class_("TrumaTimerBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.TIMER_ROOM), + "TIMER_WATER": (truma_inetbox_ns.class_("TrumaTimerBinarySensor", binary_sensor.BinarySensor, cg.Component), TRUMA_BINARY_SENSOR_TYPE_dummy_ns.TIMER_WATER), +} + + +def set_default_based_on_type(): + def set_defaults_(config): + # Update type based on configuration + config[CONF_ID].type = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][0] + + # set defaults based on sensor type: + if config[CONF_TYPE] == "CP_PLUS_CONNECTED": + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = 500 # 0.5 seconds + return config + + return set_defaults_ + + +CONFIG_SCHEMA = ( + binary_sensor.binary_sensor_schema(TrumaSensor) + .extend({ + cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TYPE): cv.enum(CONF_SUPPORTED_TYPE, upper=True), + }).extend(cv.COMPONENT_SCHEMA) +) +FINAL_VALIDATE_SCHEMA = set_default_based_on_type() + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_TRUMA_INETBOX_ID]) + + if CONF_SUPPORTED_TYPE[config[CONF_TYPE]][1]: + cg.add(var.set_type(CONF_SUPPORTED_TYPE[config[CONF_TYPE]][1])) diff --git a/components/truma_inetbox/binary_sensor/enum.h b/components/truma_inetbox/binary_sensor/enum.h new file mode 100644 index 0000000..0d3d572 --- /dev/null +++ b/components/truma_inetbox/binary_sensor/enum.h @@ -0,0 +1,20 @@ +#pragma once + +namespace esphome { +namespace truma_inetbox { + +enum class TRUMA_BINARY_SENSOR_TYPE { + HEATER_ROOM, + HEATER_WATER, + HEATER_GAS, + HEATER_MIX_1, + HEATER_MIX_2, + HEATER_ELECTRICITY, + + TIMER_ACTIVE, + TIMER_ROOM, + TIMER_WATER, +}; + +} +} \ No newline at end of file diff --git a/components/truma_inetbox/climate/TrumaRoomClimate.cpp b/components/truma_inetbox/climate/TrumaRoomClimate.cpp new file mode 100644 index 0000000..54aa955 --- /dev/null +++ b/components/truma_inetbox/climate/TrumaRoomClimate.cpp @@ -0,0 +1,105 @@ +#include "TrumaRoomClimate.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.truma_room_climate"; +void TrumaRoomClimate::setup() { + this->parent_->register_listener([this](const StatusFrameHeater *status_heater) { + // Publish updated state + this->target_temperature = temp_code_to_decimal(status_heater->target_temp_room); + this->current_temperature = temp_code_to_decimal(status_heater->current_temp_room); + this->mode = (status_heater->operating_status >= OperatingStatus::OPERATING_STATUS_START_OR_COOL_DOWN) + ? climate::CLIMATE_MODE_HEAT + : climate::CLIMATE_MODE_OFF; + + switch (status_heater->heating_mode) { + case HeatingMode::HEATING_MODE_ECO: + this->preset = climate::CLIMATE_PRESET_ECO; + break; + case HeatingMode::HEATING_MODE_HIGH: + this->preset = climate::CLIMATE_PRESET_COMFORT; + break; + case HeatingMode::HEATING_MODE_BOOST: + this->preset = climate::CLIMATE_PRESET_BOOST; + break; + default: + this->preset = climate::CLIMATE_PRESET_NONE; + break; + } + this->publish_state(); + }); +} + +void TrumaRoomClimate::dump_config() { ESP_LOGCONFIG(TAG, "Truma Room Climate"); } + +void TrumaRoomClimate::control(const climate::ClimateCall &call) { + if (call.get_target_temperature().has_value()) { + float temp = *call.get_target_temperature(); + this->parent_->action_heater_room(static_cast(temp)); + } + + if (call.get_mode().has_value()) { + // User requested mode change + climate::ClimateMode mode = *call.get_mode(); + auto status_heater = this->parent_->get_status_heater(); + switch (mode) { + case climate::CLIMATE_MODE_HEAT: + if (status_heater->target_temp_room == TargetTemp::TARGET_TEMP_OFF) { + this->parent_->action_heater_room(5); + } + break; + default: + this->parent_->action_heater_room(0); + break; + } + } + + if (call.get_preset().has_value()) { + climate::ClimatePreset pres = *call.get_preset(); + auto status_heater = this->parent_->get_status_heater(); + auto current_target_temp = temp_code_to_decimal(status_heater->target_temp_room); + if (call.get_target_temperature().has_value()) { + current_target_temp = *call.get_target_temperature(); + } + switch (pres) { + case climate::CLIMATE_PRESET_ECO: + this->parent_->action_heater_room(current_target_temp, HeatingMode::HEATING_MODE_ECO); + break; + case climate::CLIMATE_PRESET_COMFORT: + this->parent_->action_heater_room(current_target_temp, HeatingMode::HEATING_MODE_HIGH); + break; + case climate::CLIMATE_PRESET_BOOST: + this->parent_->action_heater_room(current_target_temp, HeatingMode::HEATING_MODE_BOOST); + break; + default: + this->parent_->action_heater_room(0); + break; + } + } +} + +climate::ClimateTraits TrumaRoomClimate::traits() { + // The capabilities of the climate device + auto traits = climate::ClimateTraits(); + traits.set_supports_current_temperature(true); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT}); + // traits.set_supported_fan_modes({{ + // climate::CLIMATE_FAN_LOW, + // climate::CLIMATE_FAN_MEDIUM, + // climate::CLIMATE_FAN_HIGH, + // }}); + traits.set_supported_presets({{ + climate::CLIMATE_PRESET_NONE, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_COMFORT, + climate::CLIMATE_PRESET_BOOST, + }}); + traits.set_visual_min_temperature(5); + traits.set_visual_max_temperature(30); + traits.set_visual_temperature_step(1); + return traits; +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/climate/TrumaRoomClimate.h b/components/truma_inetbox/climate/TrumaRoomClimate.h new file mode 100644 index 0000000..b0d2f86 --- /dev/null +++ b/components/truma_inetbox/climate/TrumaRoomClimate.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { +class TrumaRoomClimate : public Component, public climate::Climate, public Parented { + public: + void setup() override; + + void dump_config() override; + + void control(const climate::ClimateCall &call) override; + + climate::ClimateTraits traits() override; + + protected: + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/climate/TrumaWaterClimate.cpp b/components/truma_inetbox/climate/TrumaWaterClimate.cpp new file mode 100644 index 0000000..bd28b61 --- /dev/null +++ b/components/truma_inetbox/climate/TrumaWaterClimate.cpp @@ -0,0 +1,54 @@ +#include "TrumaWaterClimate.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.truma_water_climate"; +void TrumaWaterClimate::setup() { + this->parent_->register_listener([this](const StatusFrameHeater *status_heater) { + // Publish updated state + this->target_temperature = temp_code_to_decimal(status_heater->target_temp_water); + this->current_temperature = temp_code_to_decimal(status_heater->current_temp_water); + this->mode = (status_heater->target_temp_water == TargetTemp::TARGET_TEMP_OFF) ? climate::CLIMATE_MODE_OFF + : climate::CLIMATE_MODE_HEAT; + this->publish_state(); + }); +} + +void TrumaWaterClimate::dump_config() { ESP_LOGCONFIG(TAG, "Truma Climate"); } + +void TrumaWaterClimate::control(const climate::ClimateCall &call) { + if (call.get_target_temperature().has_value()) { + float temp = *call.get_target_temperature(); + this->parent_->action_heater_water(static_cast(temp)); + } + + if (call.get_mode().has_value()) { + climate::ClimateMode mode = *call.get_mode(); + auto status_heater = this->parent_->get_status_heater(); + switch (mode) { + case climate::CLIMATE_MODE_HEAT: + if (status_heater->target_temp_water == TargetTemp::TARGET_TEMP_OFF) { + this->parent_->action_heater_water(40); + } + break; + default: + this->parent_->action_heater_water(0); + break; + } + } +} + +climate::ClimateTraits TrumaWaterClimate::traits() { + // The capabilities of the climate device + auto traits = climate::ClimateTraits(); + traits.set_supports_current_temperature(true); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT}); + traits.set_visual_min_temperature(40); + traits.set_visual_max_temperature(80); + traits.set_visual_temperature_step(20); + return traits; +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/climate/TrumaWaterClimate.h b/components/truma_inetbox/climate/TrumaWaterClimate.h new file mode 100644 index 0000000..bce7a4b --- /dev/null +++ b/components/truma_inetbox/climate/TrumaWaterClimate.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { +class TrumaWaterClimate : public Component, public climate::Climate, public Parented { + public: + void setup() override; + + void dump_config() override; + + void control(const climate::ClimateCall &call) override; + + climate::ClimateTraits traits() override; + + protected: + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/climate/__init__.py b/components/truma_inetbox/climate/__init__.py new file mode 100644 index 0000000..bee2ef5 --- /dev/null +++ b/components/truma_inetbox/climate/__init__.py @@ -0,0 +1,45 @@ +from esphome.components import climate +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + CONF_TYPE, +) +from .. import truma_inetbox_ns, CONF_TRUMA_INETBOX_ID, TrumaINetBoxApp + +DEPENDENCIES = ["truma_inetbox"] +CODEOWNERS = ["@Fabian-Schmidt"] + +TrumaClimate = truma_inetbox_ns.class_( + "TrumaClimate", climate.Climate, cg.Component) + +CONF_SUPPORTED_TYPE = { + "ROOM": truma_inetbox_ns.class_("TrumaRoomClimate", climate.Climate, cg.Component), + "WATER": truma_inetbox_ns.class_("TrumaWaterClimate", climate.Climate, cg.Component), +} + + +def set_default_based_on_type(): + def set_defaults_(config): + # update the class + config[CONF_ID].type = CONF_SUPPORTED_TYPE[config[CONF_TYPE]] + return config + + return set_defaults_ + + +CONFIG_SCHEMA = climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TrumaClimate), + cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TYPE): cv.enum(CONF_SUPPORTED_TYPE, upper=True), + } +).extend(cv.COMPONENT_SCHEMA) +FINAL_VALIDATE_SCHEMA = set_default_based_on_type() + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await climate.register_climate(var, config) + await cg.register_parented(var, config[CONF_TRUMA_INETBOX_ID]) diff --git a/components/truma_inetbox/entity_helpers.py b/components/truma_inetbox/entity_helpers.py new file mode 100644 index 0000000..81ce1b5 --- /dev/null +++ b/components/truma_inetbox/entity_helpers.py @@ -0,0 +1,65 @@ +import esphome.final_validate as fv +from esphome import core +from esphome.const import CONF_ID + +def count_id_usage(property_to_update, property_to_count, property_value): + """Validator that counts a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. + If a property is already set, it will not be updated. + """ + + def _walk_config(config, path): + walk = [path] if not isinstance(path, list) else path + for item_or_index in walk: + config = config[item_or_index] + return config + + def _count_config_value(config, conf_name_list, conf_value): + ret = 0 + + if isinstance(config, (list, tuple)): + for config_item in config: + ret += _count_config_value(config_item, + conf_name_list, conf_value) + for conf_name in conf_name_list: + if conf_name in config and isinstance(config, (tuple)) and config[0] == conf_name and isinstance(config[1], (core.ID)) and config[1].type is conf_value: + ret += 1 + elif isinstance(config, (dict)): + for config_item in config.items(): + ret += _count_config_value(config_item, + conf_name_list, conf_value) + + return ret + + def inherit_property(config): + # Ensure `property_to_count` is a list + property_to_count_list = [property_to_count] if not isinstance( + property_to_count, list) else property_to_count + + # Split the property into its path and name + if not isinstance(property_to_update, list): + property_path, property = [], property_to_update + else: + property_path, property = property_to_update[:- + 1], property_to_update[-1] + + # Check if the property is accessible + try: + config_part = _walk_config(config, property_path) + except KeyError: + return config + + # Only update the property if it does not exist yet + if property not in config_part: + fconf = fv.full_config.get() + + count = _count_config_value( + fconf, property_to_count_list, property_value) + + path = fconf.get_path_for_id(config[CONF_ID])[:-1] + this_config = _walk_config( + fconf.get_config_for_path(path), property_path + ) + this_config[property] = count + return config + + return inherit_property diff --git a/components/truma_inetbox/helpers.cpp b/components/truma_inetbox/helpers.cpp new file mode 100644 index 0000000..5c0d58e --- /dev/null +++ b/components/truma_inetbox/helpers.cpp @@ -0,0 +1,107 @@ +#include "helpers.h" +#include "esphome/core/helpers.h" +#include "TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +u_int8_t addr_parity(const u_int8_t PID) { + u_int8_t P0 = ((PID >> 0) + (PID >> 1) + (PID >> 2) + (PID >> 4)) & 1; + u_int8_t P1 = ~((PID >> 1) + (PID >> 3) + (PID >> 4) + (PID >> 5)) & 1; + return (P0 | (P1 << 1)); +} + +// sum = 0 LIN 1.X CRC, sum = PID LIN 2.X CRC Enhanced +u_int8_t data_checksum(const u_int8_t *message, u_int8_t length, uint16_t sum) { + for (u_int8_t i = 0; i < length; i++) { + sum += message[i]; + + if (sum >= 256) + sum -= 255; + } + return (~sum); +} + +float temp_code_to_decimal(u_int16_t val, float zero) { + if (val == 0) { + return zero; + } + return ((float) val) / 10.0f - 273.0f; +} + +float temp_code_to_decimal(TargetTemp val, float zero) { return temp_code_to_decimal((u_int16_t) val, zero); } + +TargetTemp decimal_to_room_temp(u_int8_t val) { + if (val == 0) { + return TargetTemp::TARGET_TEMP_OFF; + } + if (val <= 5) { + return TargetTemp::TARGET_TEMP_ROOM_MIN; + } + if (val >= 30) { + return TargetTemp::TARGET_TEMP_ROOM_MAX; + } + return (TargetTemp) ((((u_int16_t) val) + 273) * 10); +} + +TargetTemp decimal_to_room_temp(float val) { + if (val == NAN) { + return TargetTemp::TARGET_TEMP_OFF; + } + if (val <= 5) { + return TargetTemp::TARGET_TEMP_ROOM_MIN; + } + if (val >= 30) { + return TargetTemp::TARGET_TEMP_ROOM_MAX; + } + return (TargetTemp) ((val + 273) * 10); +} + +TargetTemp deciaml_to_water_temp(u_int8_t val) { + if (val < 40) { + return TargetTemp::TARGET_TEMP_OFF; + } else if (val >= 40 && val < 60) { + return TargetTemp::TARGET_TEMP_WATER_ECO; + } else if (val >= 60 && val < 80) { + return TargetTemp::TARGET_TEMP_WATER_HIGH; + } else { + return TargetTemp::TARGET_TEMP_WATER_BOOST; + } +} + +float offset_code_to_decimal(TempOffset val) { return ((float) val) / 10.0f - 17.0f; } + +const std::string operating_status_to_str(OperatingStatus val) { + if (val == OperatingStatus::OPERATING_STATUS_OFF) { + return "OFF"; + } else if (val == OperatingStatus::OPERATING_STATUS_WARNING) { + return "WARNING"; + } else if (val == OperatingStatus::OPERATING_STATUS_START_OR_COOL_DOWN) { + return "START/COOL DOWN"; + } else if (val == OperatingStatus::OPERATING_STATUS_ON_5) { + return "ON (5)"; + } else if (val == OperatingStatus::OPERATING_STATUS_ON_6) { + return "ON (6)"; + } else if (val == OperatingStatus::OPERATING_STATUS_ON_7) { + return "ON (7)"; + } else if (val == OperatingStatus::OPERATING_STATUS_ON_8) { + return "ON (8)"; + } else if (val == OperatingStatus::OPERATING_STATUS_ON_9) { + return "ON (9)"; + } else { + return str_snprintf("ON %x", (uint8_t) val); + } +} + +ElectricPowerLevel decimal_to_el_power_level(u_int16_t val) { + if (val >= 1800) { + return ElectricPowerLevel::ELECTRIC_POWER_LEVEL_1800; + } else if (val >= 900) { + return ElectricPowerLevel::ELECTRIC_POWER_LEVEL_900; + } else { + return ElectricPowerLevel::ELECTRIC_POWER_LEVEL_0; + } +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/helpers.h b/components/truma_inetbox/helpers.h new file mode 100644 index 0000000..79f3fb8 --- /dev/null +++ b/components/truma_inetbox/helpers.h @@ -0,0 +1,23 @@ +#pragma once + +#include "TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { +// First byte is service identifier and to be ignored. +const std::array truma_message_header = {0x00, 0x00, 0x1F, 0x00, 0x1E, 0x00, + 0x00, 0x22, 0xFF, 0xFF, 0xFF}; + +u_int8_t addr_parity(const u_int8_t pid); +u_int8_t data_checksum(const u_int8_t *message, u_int8_t length, uint16_t sum); +float temp_code_to_decimal(u_int16_t val, float zero = NAN); +float temp_code_to_decimal(TargetTemp val, float zero = NAN); +TargetTemp decimal_to_room_temp(u_int8_t val); +TargetTemp decimal_to_room_temp(float val); +TargetTemp deciaml_to_water_temp(u_int8_t val); +float offset_code_to_decimal(TempOffset val); +const std::string operating_status_to_str(OperatingStatus val); +ElectricPowerLevel decimal_to_el_power_level(u_int16_t val); + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/number/TrumaHeaterNumber.cpp b/components/truma_inetbox/number/TrumaHeaterNumber.cpp new file mode 100644 index 0000000..2ca3268 --- /dev/null +++ b/components/truma_inetbox/number/TrumaHeaterNumber.cpp @@ -0,0 +1,45 @@ +#include "TrumaHeaterNumber.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.sensor"; + +void TrumaHeaterNumber::setup() { + this->parent_->register_listener([this](const StatusFrameHeater *status_heater) { + switch (this->type_) { + case TRUMA_NUMBER_TYPE::TARGET_ROOM_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->target_temp_room, 0)); + break; + case TRUMA_NUMBER_TYPE::TARGET_WATER_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->target_temp_water, 0)); + break; + case TRUMA_NUMBER_TYPE::ELECTRIC_POWER_LEVEL: + this->publish_state(static_cast(status_heater->el_power_level_a)); + break; + } + }); +} + +void TrumaHeaterNumber::control(float value) { + switch (this->type_) { + case TRUMA_NUMBER_TYPE::TARGET_ROOM_TEMPERATURE: + this->parent_->action_heater_room(static_cast(value)); + break; + case TRUMA_NUMBER_TYPE::TARGET_WATER_TEMPERATURE: + this->parent_->action_heater_water(static_cast(value)); + break; + case TRUMA_NUMBER_TYPE::ELECTRIC_POWER_LEVEL: + this->parent_->action_heater_water(static_cast(value)); + break; + } +} + +void TrumaHeaterNumber::dump_config() { + ESP_LOGCONFIG("", "Truma Heater Number"); + ESP_LOGCONFIG(TAG, "Type %u", this->type_); +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/number/TrumaHeaterNumber.h b/components/truma_inetbox/number/TrumaHeaterNumber.h new file mode 100644 index 0000000..4c1ec7d --- /dev/null +++ b/components/truma_inetbox/number/TrumaHeaterNumber.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +enum class TRUMA_NUMBER_TYPE { + TARGET_ROOM_TEMPERATURE, + TARGET_WATER_TEMPERATURE, + ELECTRIC_POWER_LEVEL, +}; + +class TrumaHeaterNumber : public Component, public number::Number, public Parented { + public: + void setup() override; + void dump_config() override; + + void set_type(TRUMA_NUMBER_TYPE val) { this->type_ = val; } + + protected: + TRUMA_NUMBER_TYPE type_; + + void control(float value) override; + + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/number/__init__.py b/components/truma_inetbox/number/__init__.py new file mode 100644 index 0000000..734ce0e --- /dev/null +++ b/components/truma_inetbox/number/__init__.py @@ -0,0 +1,119 @@ +from esphome.components import number +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + CONF_UNIT_OF_MEASUREMENT, + UNIT_CELSIUS, + CONF_ICON, + ICON_THERMOMETER, + CONF_DEVICE_CLASS, + UNIT_WATT, + ICON_POWER, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_STEP, +) +from .. import truma_inetbox_ns, CONF_TRUMA_INETBOX_ID, TrumaINetBoxApp + +DEPENDENCIES = ["truma_inetbox"] +CODEOWNERS = ["@Fabian-Schmidt"] + +CONF_CLASS = "class" + +TrumaNumber = truma_inetbox_ns.class_( + "TrumaNumber", number.Number, cg.Component) + +# `TRUMA_NUMBER_TYPE` is a enum class and not a namespace but it works. +TRUMA_NUMBER_TYPE_dummy_ns = truma_inetbox_ns.namespace("TRUMA_NUMBER_TYPE") + +CONF_SUPPORTED_TYPE = { + "TARGET_ROOM_TEMPERATURE": { + CONF_CLASS: truma_inetbox_ns.class_("TrumaHeaterNumber", number.Number, cg.Component), + CONF_TYPE: TRUMA_NUMBER_TYPE_dummy_ns.TARGET_ROOM_TEMPERATURE, + CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, + CONF_ICON: ICON_THERMOMETER, + CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + CONF_MAX_VALUE: 30, + # Values between 0 and 5 are handeld as off. + CONF_MIN_VALUE: 0, + CONF_STEP: 1, + }, + "TARGET_WATER_TEMPERATURE": { + CONF_CLASS: truma_inetbox_ns.class_("TrumaHeaterNumber", number.Number, cg.Component), + CONF_TYPE: TRUMA_NUMBER_TYPE_dummy_ns.TARGET_WATER_TEMPERATURE, + CONF_UNIT_OF_MEASUREMENT: UNIT_CELSIUS, + CONF_ICON: ICON_THERMOMETER, + CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + CONF_MAX_VALUE: 80, + # Values between 0 and 40 are handeld as off. + CONF_MIN_VALUE: 0, + CONF_STEP: 20, + }, + "ELECTRIC_POWER_LEVEL": { + CONF_CLASS: truma_inetbox_ns.class_("TrumaHeaterNumber", number.Number, cg.Component), + CONF_TYPE: TRUMA_NUMBER_TYPE_dummy_ns.ELECTRIC_POWER_LEVEL, + CONF_UNIT_OF_MEASUREMENT: UNIT_WATT, + CONF_ICON: ICON_POWER, + CONF_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + CONF_MAX_VALUE: 1800, + CONF_MIN_VALUE: 0, + CONF_STEP: 900, + }, +} + + +def set_default_based_on_type(): + def set_defaults_(config): + # update the class + config[CONF_ID].type = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][CONF_CLASS] + # set defaults based on sensor type: + if CONF_UNIT_OF_MEASUREMENT not in config: + config[CONF_UNIT_OF_MEASUREMENT] = CONF_SUPPORTED_TYPE[config[CONF_TYPE] + ][CONF_UNIT_OF_MEASUREMENT] + if CONF_ICON not in config: + config[CONF_ICON] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][CONF_ICON] + if CONF_DEVICE_CLASS not in config: + config[CONF_DEVICE_CLASS] = CONF_SUPPORTED_TYPE[config[CONF_TYPE] + ][CONF_DEVICE_CLASS] + if CONF_MAX_VALUE not in config: + config[CONF_MAX_VALUE] = CONF_SUPPORTED_TYPE[config[CONF_TYPE] + ][CONF_MAX_VALUE] + if CONF_MIN_VALUE not in config: + config[CONF_MIN_VALUE] = CONF_SUPPORTED_TYPE[config[CONF_TYPE] + ][CONF_MIN_VALUE] + if CONF_STEP not in config: + config[CONF_STEP] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][CONF_STEP] + return config + + return set_defaults_ + + +CONFIG_SCHEMA = number.NUMBER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TrumaNumber), + cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TYPE): cv.enum(CONF_SUPPORTED_TYPE, upper=True), + cv.Optional(CONF_MAX_VALUE): cv.float_, + cv.Optional(CONF_MIN_VALUE): cv.float_, + cv.Optional(CONF_STEP): cv.positive_float, + } +).extend(cv.COMPONENT_SCHEMA) +FINAL_VALIDATE_SCHEMA = set_default_based_on_type() + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await number.register_number( + var, + config, + min_value=config[CONF_MIN_VALUE], + max_value=config[CONF_MAX_VALUE], + step=config[CONF_STEP], + ) + await cg.register_parented(var, config[CONF_TRUMA_INETBOX_ID]) + + cg.add(var.set_type(CONF_SUPPORTED_TYPE[config[CONF_TYPE]][CONF_TYPE])) diff --git a/components/truma_inetbox/sensor/TrumaSensor.cpp b/components/truma_inetbox/sensor/TrumaSensor.cpp new file mode 100644 index 0000000..01e7fe4 --- /dev/null +++ b/components/truma_inetbox/sensor/TrumaSensor.cpp @@ -0,0 +1,46 @@ +#include "TrumaSensor.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.sensor"; + +void TrumaSensor::setup() { + this->parent_->register_listener([this](const StatusFrameHeater *status_heater) { + switch (this->type_) { + case TRUMA_SENSOR_TYPE::CURRENT_ROOM_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->current_temp_room)); + break; + case TRUMA_SENSOR_TYPE::CURRENT_WATER_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->current_temp_water)); + break; + case TRUMA_SENSOR_TYPE::TARGET_ROOM_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->target_temp_room)); + break; + case TRUMA_SENSOR_TYPE::TARGET_WATER_TEMPERATURE: + this->publish_state(temp_code_to_decimal(status_heater->target_temp_water)); + break; + case TRUMA_SENSOR_TYPE::HEATING_MODE: + this->publish_state(static_cast(status_heater->heating_mode)); + break; + case TRUMA_SENSOR_TYPE::ELECTRIC_POWER_LEVEL: + this->publish_state(static_cast(status_heater->el_power_level_a)); + break; + case TRUMA_SENSOR_TYPE::ENERGY_MIX: + this->publish_state(static_cast(status_heater->energy_mix_a)); + break; + case TRUMA_SENSOR_TYPE::OPERATING_STATUS: + this->publish_state(static_cast(status_heater->operating_status)); + break; + } + }); +} + +void TrumaSensor::dump_config() { + LOG_SENSOR("", "Truma Sensor", this); + ESP_LOGCONFIG(TAG, "Type %u", this->type_); +} +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/sensor/TrumaSensor.h b/components/truma_inetbox/sensor/TrumaSensor.h new file mode 100644 index 0000000..29a6369 --- /dev/null +++ b/components/truma_inetbox/sensor/TrumaSensor.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { +enum class TRUMA_SENSOR_TYPE { + CURRENT_ROOM_TEMPERATURE, + CURRENT_WATER_TEMPERATURE, + TARGET_ROOM_TEMPERATURE, + TARGET_WATER_TEMPERATURE, + HEATING_MODE, + ELECTRIC_POWER_LEVEL, + ENERGY_MIX, + OPERATING_STATUS, +}; + +class TrumaSensor : public Component, public sensor::Sensor, public Parented { + public: + void setup() override; + void dump_config() override; + + void set_type(TRUMA_SENSOR_TYPE val) { this->type_ = val; } + + protected: + TRUMA_SENSOR_TYPE type_; + + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/sensor/__init__.py b/components/truma_inetbox/sensor/__init__.py new file mode 100644 index 0000000..f7b6864 --- /dev/null +++ b/components/truma_inetbox/sensor/__init__.py @@ -0,0 +1,82 @@ +from esphome.components import sensor +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + CONF_UNIT_OF_MEASUREMENT, + UNIT_CELSIUS, + CONF_ICON, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + UNIT_WATT, + UNIT_EMPTY, + ICON_GAS_CYLINDER, + ICON_POWER, +) +from .. import truma_inetbox_ns, CONF_TRUMA_INETBOX_ID, TrumaINetBoxApp + +DEPENDENCIES = ["truma_inetbox"] +CODEOWNERS = ["@Fabian-Schmidt"] + +TrumaSensor = truma_inetbox_ns.class_( + "TrumaSensor", sensor.Sensor, cg.Component) + +# `TRUMA_SENSOR_TYPE` is a enum class and not a namespace but it works. +TRUMA_SENSOR_TYPE_dummy_ns = truma_inetbox_ns.namespace("TRUMA_SENSOR_TYPE") + +# 0 - C++ enum +# 1 - CONF_UNIT_OF_MEASUREMENT +# 2 - CONF_ICON +# 3 - CONF_ACCURACY_DECIMALS +# 4 - CONF_DEVICE_CLASS +CONF_SUPPORTED_TYPE = { + "CURRENT_ROOM_TEMPERATURE": (TRUMA_SENSOR_TYPE_dummy_ns.CURRENT_ROOM_TEMPERATURE, UNIT_CELSIUS, ICON_THERMOMETER, 1, DEVICE_CLASS_TEMPERATURE), + "CURRENT_WATER_TEMPERATURE": (TRUMA_SENSOR_TYPE_dummy_ns.CURRENT_WATER_TEMPERATURE, UNIT_CELSIUS, ICON_THERMOMETER, 1, DEVICE_CLASS_TEMPERATURE), + "TARGET_ROOM_TEMPERATURE": (TRUMA_SENSOR_TYPE_dummy_ns.TARGET_ROOM_TEMPERATURE, UNIT_CELSIUS, ICON_THERMOMETER, 0, DEVICE_CLASS_TEMPERATURE), + "TARGET_WATER_TEMPERATURE": (TRUMA_SENSOR_TYPE_dummy_ns.TARGET_WATER_TEMPERATURE, UNIT_CELSIUS, ICON_THERMOMETER, 0, DEVICE_CLASS_TEMPERATURE), + "HEATING_MODE": (TRUMA_SENSOR_TYPE_dummy_ns.HEATING_MODE, UNIT_EMPTY, ICON_THERMOMETER, 0, DEVICE_CLASS_TEMPERATURE), + "ELECTRIC_POWER_LEVEL": (TRUMA_SENSOR_TYPE_dummy_ns.ELECTRIC_POWER_LEVEL, UNIT_WATT, ICON_POWER, 0, DEVICE_CLASS_TEMPERATURE), + "ENERGY_MIX": (TRUMA_SENSOR_TYPE_dummy_ns.ENERGY_MIX, UNIT_EMPTY, ICON_GAS_CYLINDER, 0, DEVICE_CLASS_TEMPERATURE), + "OPERATING_STATUS": (TRUMA_SENSOR_TYPE_dummy_ns.OPERATING_STATUS, UNIT_EMPTY, ICON_POWER, 0, DEVICE_CLASS_TEMPERATURE), +} + + +def set_default_based_on_type(): + def set_defaults_(config): + # set defaults based on sensor type: + if CONF_UNIT_OF_MEASUREMENT not in config: + config[CONF_UNIT_OF_MEASUREMENT] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][1] + if CONF_ICON not in config: + config[CONF_ICON] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][2] + if CONF_ACCURACY_DECIMALS not in config: + config[CONF_ACCURACY_DECIMALS] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][3] + if CONF_DEVICE_CLASS not in config: + config[CONF_DEVICE_CLASS] = CONF_SUPPORTED_TYPE[config[CONF_TYPE]][4] + return config + + return set_defaults_ + + +CONFIG_SCHEMA = sensor.sensor_schema( + state_class=STATE_CLASS_MEASUREMENT +).extend( + { + cv.GenerateID(): cv.declare_id(TrumaSensor), + cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp), + cv.Required(CONF_TYPE): cv.enum(CONF_SUPPORTED_TYPE, upper=True), + } +).extend(cv.COMPONENT_SCHEMA) +FINAL_VALIDATE_SCHEMA = set_default_based_on_type() + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + await cg.register_parented(var, config[CONF_TRUMA_INETBOX_ID]) + + cg.add(var.set_type(CONF_SUPPORTED_TYPE[config[CONF_TYPE]][0])) diff --git a/components/truma_inetbox/time/TrumaTime.cpp b/components/truma_inetbox/time/TrumaTime.cpp new file mode 100644 index 0000000..20d45e2 --- /dev/null +++ b/components/truma_inetbox/time/TrumaTime.cpp @@ -0,0 +1,47 @@ +#include "TrumaTime.h" +#include "esphome/core/log.h" +#include "esphome/components/truma_inetbox/helpers.h" + +namespace esphome { +namespace truma_inetbox { + +static const char *const TAG = "truma_inetbox.time"; + +void TrumaTime::setup() { + this->parent_->register_listener([this](const StatusFrameClock *status_clock) { + if (this->auto_disable_count_ > 0) { + if (this->read_time() && this->auto_disable_) { + this->auto_disable_count_--; + } + } + }); +} + +void TrumaTime::update() {} + +void TrumaTime::dump_config() { ESP_LOGCONFIG(TAG, "Truma Time", this); } + +bool TrumaTime::read_time() { + if (!this->parent_->get_status_clock_valid()) { + return false; + } + auto status_clock = this->parent_->get_status_clock(); + + time::ESPTime rtc_time{.second = status_clock->clock_second, + .minute = status_clock->clock_minute, + .hour = status_clock->clock_hour, + .day_of_week = 1, + .day_of_month = 1, + .day_of_year = 1, // ignored by recalc_timestamp_utc(false) + .month = 1, + .year = 2020}; + if (!rtc_time.is_valid()) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return false; + } + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); + return true; +} + +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/time/TrumaTime.h b/components/truma_inetbox/time/TrumaTime.h new file mode 100644 index 0000000..3c1b626 --- /dev/null +++ b/components/truma_inetbox/time/TrumaTime.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/time/real_time_clock.h" +#include "esphome/components/truma_inetbox/TrumaiNetBoxApp.h" + +namespace esphome { +namespace truma_inetbox { + +class TrumaTime : public time::RealTimeClock, public Parented { + public: + void setup() override; + void update() override; + void dump_config() override; + + bool read_time(); + + void set_auto_disable(bool val) { this->auto_disable_ = val; } + + protected: + bool auto_disable_ = false; + u_int8_t auto_disable_count_ = 3; + + private: +}; +} // namespace truma_inetbox +} // namespace esphome \ No newline at end of file diff --git a/components/truma_inetbox/time/__init__.py b/components/truma_inetbox/time/__init__.py new file mode 100644 index 0000000..f70c01c --- /dev/null +++ b/components/truma_inetbox/time/__init__.py @@ -0,0 +1,34 @@ +from esphome.components import time +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + CONF_TYPE, +) +from .. import truma_inetbox_ns, CONF_TRUMA_INETBOX_ID, TrumaINetBoxApp + +DEPENDENCIES = ["truma_inetbox"] +CODEOWNERS = ["@Fabian-Schmidt"] + +CONF_AUTO_DISABLE = "auto_disable" + +TrumaTime = truma_inetbox_ns.class_( + "TrumaTime", time.RealTimeClock, cg.Component) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TrumaTime), + cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp), + cv.Optional(CONF_AUTO_DISABLE, default=True): cv.boolean, + } +).extend(cv.polling_component_schema("never")) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await time.register_time(var, config) + await cg.register_parented(var, config[CONF_TRUMA_INETBOX_ID]) + + if config[CONF_AUTO_DISABLE]: + cg.add(var.set_auto_disable(config[CONF_AUTO_DISABLE])) diff --git a/components/uart/__init__.py b/components/uart/__init__.py new file mode 100644 index 0000000..2139ad2 --- /dev/null +++ b/components/uart/__init__.py @@ -0,0 +1,335 @@ +from typing import Optional + +import esphome.codegen as cg +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome.yaml_util import make_data_base +from esphome import pins, automation +from esphome.const import ( + CONF_BAUD_RATE, + CONF_ID, + CONF_NUMBER, + CONF_RX_PIN, + CONF_TX_PIN, + CONF_UART_ID, + CONF_DATA, + CONF_RX_BUFFER_SIZE, + CONF_INVERTED, + CONF_INVERT, + CONF_TRIGGER_ID, + CONF_SEQUENCE, + CONF_TIMEOUT, + CONF_DEBUG, + CONF_DIRECTION, + CONF_AFTER, + CONF_BYTES, + CONF_DELIMITER, + CONF_DUMMY_RECEIVER, + CONF_DUMMY_RECEIVER_ID, + CONF_LAMBDA, +) +from esphome.core import CORE + +CODEOWNERS = ["@esphome/core"] +uart_ns = cg.esphome_ns.namespace("uart") +UARTComponent = uart_ns.class_("UARTComponent") + +IDFUARTComponent = uart_ns.class_( + "truma_IDFUARTComponent", UARTComponent, cg.Component) +ESP32ArduinoUARTComponent = uart_ns.class_( + "truma_ESP32ArduinoUARTComponent", UARTComponent, cg.Component +) +ESP8266UartComponent = uart_ns.class_( + "ESP8266UartComponent", UARTComponent, cg.Component +) +RP2040UartComponent = uart_ns.class_( + "truma_RP2040UartComponent", UARTComponent, cg.Component) + +UARTDevice = uart_ns.class_("UARTDevice") +UARTWriteAction = uart_ns.class_("UARTWriteAction", automation.Action) +UARTDebugger = uart_ns.class_("UARTDebugger", cg.Component, automation.Action) +UARTDummyReceiver = uart_ns.class_("UARTDummyReceiver", cg.Component) +MULTI_CONF = True + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, str): + return value + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +def validate_rx_pin(value): + value = pins.internal_gpio_input_pin_schema(value) + if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: + raise cv.Invalid( + "Pins GPIO16 and GPIO17 cannot be used as RX pins on ESP8266.") + return value + + +def validate_invert_esp32(config): + if ( + CORE.is_esp32 + and CONF_TX_PIN in config + and CONF_RX_PIN in config + and config[CONF_TX_PIN][CONF_INVERTED] != config[CONF_RX_PIN][CONF_INVERTED] + ): + raise cv.Invalid( + "Different invert values for TX and RX pin are not (yet) supported for ESP32." + ) + return config + + +def _uart_declare_type(value): + if CORE.is_esp8266: + return cv.declare_id(ESP8266UartComponent)(value) + if CORE.is_esp32: + if CORE.using_arduino: + return cv.declare_id(ESP32ArduinoUARTComponent)(value) + if CORE.using_esp_idf: + return cv.declare_id(IDFUARTComponent)(value) + if CORE.is_rp2040: + return cv.declare_id(RP2040UartComponent)(value) + raise NotImplementedError + + +UARTParityOptions = uart_ns.enum("UARTParityOptions") +UART_PARITY_OPTIONS = { + "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, + "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, + "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, +} + +CONF_STOP_BITS = "stop_bits" +CONF_DATA_BITS = "data_bits" +CONF_PARITY = "parity" + +UARTDirection = uart_ns.enum("UARTDirection") +UART_DIRECTIONS = { + "RX": UARTDirection.UART_DIRECTION_RX, + "TX": UARTDirection.UART_DIRECTION_TX, + "BOTH": UARTDirection.UART_DIRECTION_BOTH, +} + +# The reason for having CONF_BYTES at 150 by default: +# +# The log message buffer size is 512 bytes by default. About 35 bytes are +# used for the log prefix. That leaves us with 477 bytes for logging data. +# The default log output is hex, which uses 3 characters per represented +# byte (2 hex chars + 1 separator). That means that 477 / 3 = 159 bytes +# can be represented in a single log line. Using 150, because people love +# round numbers. +AFTER_DEFAULTS = {CONF_BYTES: 150, CONF_TIMEOUT: "100ms"} + +# By default, log in hex format when no specific sequence is provided. +DEFAULT_DEBUG_OUTPUT = "UARTDebug::log_hex(direction, bytes, ':');" +DEFAULT_SEQUENCE = [{CONF_LAMBDA: make_data_base(DEFAULT_DEBUG_OUTPUT)}] + + +def maybe_empty_debug(value): + if value is None: + value = {} + return DEBUG_SCHEMA(value) + + +DEBUG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UARTDebugger), + cv.Optional(CONF_DIRECTION, default="BOTH"): cv.enum( + UART_DIRECTIONS, upper=True + ), + cv.Optional(CONF_AFTER, default=AFTER_DEFAULTS): cv.Schema( + { + cv.Optional( + CONF_BYTES, default=AFTER_DEFAULTS[CONF_BYTES] + ): cv.validate_bytes, + cv.Optional( + CONF_TIMEOUT, default=AFTER_DEFAULTS[CONF_TIMEOUT] + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_DELIMITER): cv.templatable(validate_raw_data), + } + ), + cv.Optional( + CONF_SEQUENCE, default=DEFAULT_SEQUENCE + ): automation.validate_automation(), + cv.Optional(CONF_DUMMY_RECEIVER, default=False): cv.boolean, + cv.GenerateID(CONF_DUMMY_RECEIVER_ID): cv.declare_id(UARTDummyReceiver), + } +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): _uart_declare_type, + cv.Required(CONF_BAUD_RATE): cv.int_range(min=1), + cv.Optional(CONF_TX_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_RX_PIN): validate_rx_pin, + cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.validate_bytes, + cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_INVERT): cv.invalid( + "This option has been removed. Please instead use invert in the tx/rx pin schemas." + ), + cv.Optional(CONF_DEBUG): maybe_empty_debug, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN), + validate_invert_esp32, +) + + +async def debug_to_code(config, parent): + trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], parent) + await cg.register_component(trigger, config) + for action in config[CONF_SEQUENCE]: + await automation.build_automation( + trigger, + [(UARTDirection, "direction"), + (cg.std_vector.template(cg.uint8), "bytes")], + action, + ) + cg.add(trigger.set_direction(config[CONF_DIRECTION])) + after = config[CONF_AFTER] + cg.add(trigger.set_after_bytes(after[CONF_BYTES])) + cg.add(trigger.set_after_timeout(after[CONF_TIMEOUT])) + if CONF_DELIMITER in after: + data = after[CONF_DELIMITER] + if isinstance(data, bytes): + data = list(data) + for byte in after[CONF_DELIMITER]: + cg.add(trigger.add_delimiter_byte(byte)) + if config[CONF_DUMMY_RECEIVER]: + dummy = cg.new_Pvariable(config[CONF_DUMMY_RECEIVER_ID], parent) + await cg.register_component(dummy, {}) + cg.add_define("USE_UART_DEBUGGER") + + +async def to_code(config): + cg.add_global(uart_ns.using) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) + + if CONF_TX_PIN in config: + tx_pin = await cg.gpio_pin_expression(config[CONF_TX_PIN]) + cg.add(var.set_tx_pin(tx_pin)) + if CONF_RX_PIN in config: + rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) + cg.add(var.set_rx_pin(rx_pin)) + cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) + cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) + cg.add(var.set_data_bits(config[CONF_DATA_BITS])) + cg.add(var.set_parity(config[CONF_PARITY])) + + if CONF_DEBUG in config: + await debug_to_code(config[CONF_DEBUG], var) + + +# A schema to use for all UART devices, all UART integrations must extend this! +UART_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UART_ID): cv.use_id(UARTComponent), + } +) + +KEY_UART_DEVICES = "uart_devices" + + +def final_validate_device_schema( + name: str, + *, + baud_rate: Optional[int] = None, + require_tx: bool = False, + require_rx: bool = False, +): + def validate_baud_rate(value): + if value != baud_rate: + raise cv.Invalid( + f"Component {name} required baud rate {baud_rate} for the uart bus" + ) + return value + + def validate_pin(opt, device): + def validator(value): + if opt in device: + raise cv.Invalid( + f"The uart {opt} is used both by {name} and {device[opt]}, " + f"but can only be used by one. Please create a new uart bus for {name}." + ) + device[opt] = name + return value + + return validator + + def validate_hub(hub_config): + hub_schema = {} + uart_id = hub_config[CONF_ID] + devices = fv.full_config.get().data.setdefault(KEY_UART_DEVICES, {}) + device = devices.setdefault(uart_id, {}) + + if require_tx: + hub_schema[ + cv.Required( + CONF_TX_PIN, + msg=f"Component {name} requires this uart bus to declare a tx_pin", + ) + ] = validate_pin(CONF_TX_PIN, device) + if require_rx: + hub_schema[ + cv.Required( + CONF_RX_PIN, + msg=f"Component {name} requires this uart bus to declare a rx_pin", + ) + ] = validate_pin(CONF_RX_PIN, device) + if baud_rate is not None: + hub_schema[cv.Required(CONF_BAUD_RATE)] = validate_baud_rate + return cv.Schema(hub_schema, extra=cv.ALLOW_EXTRA)(hub_config) + + return cv.Schema( + {cv.Required(CONF_UART_ID) : fv.id_declaration_match_schema(validate_hub)}, + extra=cv.ALLOW_EXTRA, + ) + + +async def register_uart_device(var, config): + """Register a UART device, setting up all the internal values. + + This is a coroutine, you need to await it with a 'yield' expression! + """ + parent = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_uart_parent(parent)) + + +@automation.register_action( + "uart.write", + UARTWriteAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(UARTComponent), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, + ), +) +async def uart_write_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + cg.add(var.set_data_static(data)) + return var diff --git a/components/uart/automation.h b/components/uart/automation.h new file mode 100644 index 0000000..b6a50ea --- /dev/null +++ b/components/uart/automation.h @@ -0,0 +1,38 @@ +#pragma once + +#include "uart.h" +#include "esphome/core/automation.h" + +#include + +namespace esphome { +namespace uart { + +template class UARTWriteAction : public Action, public Parented { + public: + void set_data_template(std::function(Ts...)> func) { + this->data_func_ = func; + this->static_ = false; + } + void set_data_static(const std::vector &data) { + this->data_static_ = data; + this->static_ = true; + } + + void play(Ts... x) override { + if (this->static_) { + this->parent_->write_array(this->data_static_); + } else { + auto val = this->data_func_(x...); + this->parent_->write_array(val); + } + } + + protected: + bool static_{false}; + std::function(Ts...)> data_func_{}; + std::vector data_static_{}; +}; + +} // namespace uart +} // namespace esphome diff --git a/components/uart/switch/__init__.py b/components/uart/switch/__init__.py new file mode 100644 index 0000000..60f5dda --- /dev/null +++ b/components/uart/switch/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import switch, uart +from esphome.const import CONF_DATA, CONF_SEND_EVERY +from esphome.core import HexInt +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] + +UARTSwitch = uart_ns.class_("UARTSwitch", switch.Switch, uart.UARTDevice, cg.Component) + + +CONFIG_SCHEMA = ( + switch.switch_schema(UARTSwitch, block_inverted=True) + .extend( + { + cv.Required(CONF_DATA): validate_raw_data, + cv.Optional(CONF_SEND_EVERY): cv.positive_time_period_milliseconds, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + data = config[CONF_DATA] + if isinstance(data, bytes): + data = [HexInt(x) for x in data] + cg.add(var.set_data(data)) + + if CONF_SEND_EVERY in config: + cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/components/uart/switch/uart_switch.cpp b/components/uart/switch/uart_switch.cpp new file mode 100644 index 0000000..ffed08c --- /dev/null +++ b/components/uart/switch/uart_switch.cpp @@ -0,0 +1,47 @@ +#include "uart_switch.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart.switch"; + +void UARTSwitch::loop() { + if (this->state && this->send_every_) { + const uint32_t now = millis(); + if (now - this->last_transmission_ > this->send_every_) { + this->write_command_(); + this->last_transmission_ = now; + } + } +} + +void UARTSwitch::write_command_() { + ESP_LOGD(TAG, "'%s': Sending data...", this->get_name().c_str()); + this->write_array(this->data_.data(), this->data_.size()); +} + +void UARTSwitch::write_state(bool state) { + if (!state) { + this->publish_state(false); + return; + } + + this->publish_state(true); + this->write_command_(); + + if (this->send_every_ == 0) { + this->publish_state(false); + } else { + this->last_transmission_ = millis(); + } +} +void UARTSwitch::dump_config() { + LOG_SWITCH("", "UART Switch", this); + if (this->send_every_) { + ESP_LOGCONFIG(TAG, " Send Every: %u", this->send_every_); + } +} + +} // namespace uart +} // namespace esphome diff --git a/components/uart/switch/uart_switch.h b/components/uart/switch/uart_switch.h new file mode 100644 index 0000000..4f24d76 --- /dev/null +++ b/components/uart/switch/uart_switch.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/switch/switch.h" + +#include + +namespace esphome { +namespace uart { + +class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { + public: + void loop() override; + + void set_data(const std::vector &data) { data_ = data; } + void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } + + void dump_config() override; + + protected: + void write_command_(); + void write_state(bool state) override; + std::vector data_; + uint32_t send_every_; + uint32_t last_transmission_; +}; + +} // namespace uart +} // namespace esphome diff --git a/components/uart/truma_uart_component_esp32_arduino.h b/components/uart/truma_uart_component_esp32_arduino.h new file mode 100644 index 0000000..7c795fb --- /dev/null +++ b/components/uart/truma_uart_component_esp32_arduino.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef USE_ESP32_FRAMEWORK_ARDUINO + +#include "uart_component_esp32_arduino.h" + +namespace esphome { +namespace uart { + +class truma_ESP32ArduinoUARTComponent : public ESP32ArduinoUARTComponent { + public: + bool is_hw_serial() { return true; } + HardwareSerial *get_hw_serial() { return this->hw_serial_; } + uint8_t get_hw_serial_number() { return this->number_; } +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/components/uart/truma_uart_component_esp_idf.h b/components/uart/truma_uart_component_esp_idf.h new file mode 100644 index 0000000..2dc0177 --- /dev/null +++ b/components/uart/truma_uart_component_esp_idf.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_ESP_IDF + +#include "uart_component_esp_idf.h" + +namespace esphome { +namespace uart { + +class truma_IDFUARTComponent : public IDFUARTComponent { + public: + bool is_hw_serial() { return true; } + uint8_t get_hw_serial_number() { return this->uart_num_; } +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP_IDF diff --git a/components/uart/truma_uart_component_rp2040.h b/components/uart/truma_uart_component_rp2040.h new file mode 100644 index 0000000..64381e9 --- /dev/null +++ b/components/uart/truma_uart_component_rp2040.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_RP2040 + +#include "uart_component_rp2040.h" + +namespace esphome { +namespace uart { + +class truma_RP2040UartComponent : public RP2040UartComponent { + public: + bool is_hw_serial() { return this->hw_serial_; } + HardwareSerial *get_hw_serial() { return this->serial_; } +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_RP2040 diff --git a/components/uart/uart.cpp b/components/uart/uart.cpp new file mode 100644 index 0000000..22a22e2 --- /dev/null +++ b/components/uart/uart.cpp @@ -0,0 +1,46 @@ +#include "uart.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart"; + +void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, + uint8_t data_bits) { + if (this->parent_->get_baud_rate() != baud_rate) { + ESP_LOGE(TAG, " Invalid baud_rate: Integration requested baud_rate %u but you have %u!", baud_rate, + this->parent_->get_baud_rate()); + } + if (this->parent_->get_stop_bits() != stop_bits) { + ESP_LOGE(TAG, " Invalid stop bits: Integration requested stop_bits %u but you have %u!", stop_bits, + this->parent_->get_stop_bits()); + } + if (this->parent_->get_data_bits() != data_bits) { + ESP_LOGE(TAG, " Invalid number of data bits: Integration requested %u data bits but you have %u!", data_bits, + this->parent_->get_data_bits()); + } + if (this->parent_->get_parity() != parity) { + ESP_LOGE(TAG, " Invalid parity: Integration requested parity %s but you have %s!", + LOG_STR_ARG(parity_to_str(parity)), LOG_STR_ARG(parity_to_str(this->parent_->get_parity()))); + } +} + +const LogString *parity_to_str(UARTParityOptions parity) { + switch (parity) { + case UART_CONFIG_PARITY_NONE: + return LOG_STR("NONE"); + case UART_CONFIG_PARITY_EVEN: + return LOG_STR("EVEN"); + case UART_CONFIG_PARITY_ODD: + return LOG_STR("ODD"); + default: + return LOG_STR("UNKNOWN"); + } +} + +} // namespace uart +} // namespace esphome diff --git a/components/uart/uart.h b/components/uart/uart.h new file mode 100644 index 0000000..d41dbe2 --- /dev/null +++ b/components/uart/uart.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +class UARTDevice { + public: + UARTDevice() = default; + UARTDevice(UARTComponent *parent) : parent_(parent) {} + + void set_uart_parent(UARTComponent *parent) { this->parent_ = parent; } + + void write_byte(uint8_t data) { this->parent_->write_byte(data); } + + void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array(const std::vector &data) { this->parent_->write_array(data); } + template void write_array(const std::array &data) { + this->parent_->write_array(data.data(), data.size()); + } + + void write_str(const char *str) { this->parent_->write_str(str); } + + bool read_byte(uint8_t *data) { return this->parent_->read_byte(data); } + bool peek_byte(uint8_t *data) { return this->parent_->peek_byte(data); } + + bool read_array(uint8_t *data, size_t len) { return this->parent_->read_array(data, len); } + template optional> read_array() { // NOLINT + std::array res; + if (!this->read_array(res.data(), N)) { + return {}; + } + return res; + } + + int available() { return this->parent_->available(); } + + void flush() { return this->parent_->flush(); } + + // Compat APIs + int read() { + uint8_t data; + if (!this->read_byte(&data)) + return -1; + return data; + } + size_t write(uint8_t data) { + this->write_byte(data); + return 1; + } + int peek() { + uint8_t data; + if (!this->peek_byte(&data)) + return -1; + return data; + } + + /// Check that the configuration of the UART bus matches the provided values and otherwise print a warning + void check_uart_settings(uint32_t baud_rate, uint8_t stop_bits = 1, + UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); + + protected: + UARTComponent *parent_{nullptr}; +}; + +} // namespace uart +} // namespace esphome diff --git a/components/uart/uart_component.cpp b/components/uart/uart_component.cpp new file mode 100644 index 0000000..09b8c97 --- /dev/null +++ b/components/uart/uart_component.cpp @@ -0,0 +1,24 @@ +#include "uart_component.h" + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart"; + +bool UARTComponent::check_read_timeout_(size_t len) { + if (this->available() >= int(len)) + return true; + + uint32_t start_time = millis(); + while (this->available() < int(len)) { + if (millis() - start_time > 100) { + ESP_LOGE(TAG, "Reading from UART timed out at byte %u!", this->available()); + return false; + } + yield(); + } + return true; +} + +} // namespace uart +} // namespace esphome diff --git a/components/uart/uart_component.h b/components/uart/uart_component.h new file mode 100644 index 0000000..42702cf --- /dev/null +++ b/components/uart/uart_component.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#ifdef USE_UART_DEBUGGER +#include "esphome/core/automation.h" +#endif + +namespace esphome { +namespace uart { + +enum UARTParityOptions { + UART_CONFIG_PARITY_NONE, + UART_CONFIG_PARITY_EVEN, + UART_CONFIG_PARITY_ODD, +}; + +#ifdef USE_UART_DEBUGGER +enum UARTDirection { + UART_DIRECTION_RX, + UART_DIRECTION_TX, + UART_DIRECTION_BOTH, +}; +#endif + +const LogString *parity_to_str(UARTParityOptions parity); + +class UARTComponent { + public: + void write_array(const std::vector &data) { this->write_array(&data[0], data.size()); } + void write_byte(uint8_t data) { this->write_array(&data, 1); }; + void write_str(const char *str) { + const auto *data = reinterpret_cast(str); + this->write_array(data, strlen(str)); + }; + + virtual void write_array(const uint8_t *data, size_t len) = 0; + + bool read_byte(uint8_t *data) { return this->read_array(data, 1); }; + virtual bool peek_byte(uint8_t *data) = 0; + virtual bool read_array(uint8_t *data, size_t len) = 0; + + /// Return available number of bytes. + virtual int available() = 0; + /// Block until all bytes have been written to the UART bus. + virtual void flush() = 0; + + void set_tx_pin(InternalGPIOPin *tx_pin) { this->tx_pin_ = tx_pin; } + void set_rx_pin(InternalGPIOPin *rx_pin) { this->rx_pin_ = rx_pin; } + void set_rx_buffer_size(size_t rx_buffer_size) { this->rx_buffer_size_ = rx_buffer_size; } + size_t get_rx_buffer_size() { return this->rx_buffer_size_; } + + void set_stop_bits(uint8_t stop_bits) { this->stop_bits_ = stop_bits; } + uint8_t get_stop_bits() const { return this->stop_bits_; } + void set_data_bits(uint8_t data_bits) { this->data_bits_ = data_bits; } + uint8_t get_data_bits() const { return this->data_bits_; } + void set_parity(UARTParityOptions parity) { this->parity_ = parity; } + UARTParityOptions get_parity() const { return this->parity_; } + void set_baud_rate(uint32_t baud_rate) { baud_rate_ = baud_rate; } + uint32_t get_baud_rate() const { return baud_rate_; } + +#ifdef USE_UART_DEBUGGER + void add_debug_callback(std::function &&callback) { + this->debug_callback_.add(std::move(callback)); + } +#endif + + protected: + virtual void check_logger_conflict() = 0; + bool check_read_timeout_(size_t len = 1); + + InternalGPIOPin *tx_pin_; + InternalGPIOPin *rx_pin_; + size_t rx_buffer_size_; + uint32_t baud_rate_; + uint8_t stop_bits_; + uint8_t data_bits_; + UARTParityOptions parity_; +#ifdef USE_UART_DEBUGGER + CallbackManager debug_callback_{}; +#endif +}; + +} // namespace uart +} // namespace esphome diff --git a/components/uart/uart_component_esp32_arduino.cpp b/components/uart/uart_component_esp32_arduino.cpp new file mode 100644 index 0000000..8bbbc1a --- /dev/null +++ b/components/uart/uart_component_esp32_arduino.cpp @@ -0,0 +1,170 @@ +#ifdef USE_ESP32_FRAMEWORK_ARDUINO +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "uart_component_esp32_arduino.h" + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome { +namespace uart { +static const char *const TAG = "uart.arduino_esp32"; + +static const uint32_t UART_PARITY_EVEN = 0 << 0; +static const uint32_t UART_PARITY_ODD = 1 << 0; +static const uint32_t UART_PARITY_ENABLE = 1 << 1; +static const uint32_t UART_NB_BIT_5 = 0 << 2; +static const uint32_t UART_NB_BIT_6 = 1 << 2; +static const uint32_t UART_NB_BIT_7 = 2 << 2; +static const uint32_t UART_NB_BIT_8 = 3 << 2; +static const uint32_t UART_NB_STOP_BIT_1 = 1 << 4; +static const uint32_t UART_NB_STOP_BIT_2 = 3 << 4; +static const uint32_t UART_TICK_APB_CLOCK = 1 << 27; + +uint32_t ESP32ArduinoUARTComponent::get_config() { + uint32_t config = 0; + + /* + * All bits numbers below come from + * framework-arduinoespressif32/cores/esp32/esp32-hal-uart.h + * And more specifically conf0 union in uart_dev_t. + * + * Below is bit used from conf0 union. + * : + * parity:0 0:even 1:odd + * parity_en:1 Set this bit to enable uart parity check. + * bit_num:2-4 0:5bits 1:6bits 2:7bits 3:8bits + * stop_bit_num:4-6 stop bit. 1:1bit 2:1.5bits 3:2bits + * tick_ref_always_on:27 select the clock.1:apb clock:ref_tick + */ + + if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + config |= UART_PARITY_EVEN | UART_PARITY_ENABLE; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + config |= UART_PARITY_ODD | UART_PARITY_ENABLE; + } + + switch (this->data_bits_) { + case 5: + config |= UART_NB_BIT_5; + break; + case 6: + config |= UART_NB_BIT_6; + break; + case 7: + config |= UART_NB_BIT_7; + break; + case 8: + config |= UART_NB_BIT_8; + break; + } + + if (this->stop_bits_ == 1) { + config |= UART_NB_STOP_BIT_1; + } else { + config |= UART_NB_STOP_BIT_2; + } + + config |= UART_TICK_APB_CLOCK; + + return config; +} + +void ESP32ArduinoUARTComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up UART..."); + // Use Arduino HardwareSerial UARTs if all used pins match the ones + // preconfigured by the platform. For example if RX disabled but TX pin + // is 1 we still want to use Serial. + bool is_default_tx, is_default_rx; +#ifdef CONFIG_IDF_TARGET_ESP32C3 + is_default_tx = tx_pin_ == nullptr || tx_pin_->get_pin() == 21; + is_default_rx = rx_pin_ == nullptr || rx_pin_->get_pin() == 20; +#else + is_default_tx = tx_pin_ == nullptr || tx_pin_->get_pin() == 1; + is_default_rx = rx_pin_ == nullptr || rx_pin_->get_pin() == 3; +#endif + if (is_default_tx && is_default_rx) { + this->hw_serial_ = &Serial; + } else { + static uint8_t next_uart_num = 1; + this->number_ = next_uart_num; + this->hw_serial_ = new HardwareSerial(next_uart_num++); // NOLINT(cppcoreguidelines-owning-memory) + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + bool invert = false; + if (tx_pin_ != nullptr && tx_pin_->is_inverted()) + invert = true; + if (rx_pin_ != nullptr && rx_pin_->is_inverted()) + invert = true; + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + this->hw_serial_->begin(this->baud_rate_, get_config(), rx, tx, invert); +} + +void ESP32ArduinoUARTComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus %d:", this->number_); + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + } + ESP_LOGCONFIG(TAG, " Baud Rate: %u baud", this->baud_rate_); + ESP_LOGCONFIG(TAG, " Data Bits: %u", this->data_bits_); + ESP_LOGCONFIG(TAG, " Parity: %s", LOG_STR_ARG(parity_to_str(this->parity_))); + ESP_LOGCONFIG(TAG, " Stop bits: %u", this->stop_bits_); + this->check_logger_conflict(); +} + +void ESP32ArduinoUARTComponent::write_array(const uint8_t *data, size_t len) { + this->hw_serial_->write(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} + +bool ESP32ArduinoUARTComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + *data = this->hw_serial_->peek(); + return true; +} + +bool ESP32ArduinoUARTComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + this->hw_serial_->readBytes(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} + +int ESP32ArduinoUARTComponent::available() { return this->hw_serial_->available(); } +void ESP32ArduinoUARTComponent::flush() { + ESP_LOGVV(TAG, " Flushing..."); + this->hw_serial_->flush(); +} + +void ESP32ArduinoUARTComponent::check_logger_conflict() { +#ifdef USE_LOGGER + if (this->hw_serial_ == nullptr || logger::global_logger->get_baud_rate() == 0) { + return; + } + + if (this->hw_serial_ == logger::global_logger->get_hw_serial()) { + ESP_LOGW(TAG, " You're using the same serial port for logging and the UART component. Please " + "disable logging over the serial port by setting logger->baud_rate to 0."); + } +#endif +} + +} // namespace uart +} // namespace esphome +#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/components/uart/uart_component_esp32_arduino.h b/components/uart/uart_component_esp32_arduino.h new file mode 100644 index 0000000..24fc544 --- /dev/null +++ b/components/uart/uart_component_esp32_arduino.h @@ -0,0 +1,40 @@ +#pragma once + +#ifdef USE_ESP32_FRAMEWORK_ARDUINO + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +class ESP32ArduinoUARTComponent : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + int available() override; + void flush() override; + + uint32_t get_config(); + protected: + void check_logger_conflict() override; + + HardwareSerial *hw_serial_{nullptr}; + uint8_t number_{0}; +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/components/uart/uart_component_esp8266.cpp b/components/uart/uart_component_esp8266.cpp new file mode 100644 index 0000000..529108f --- /dev/null +++ b/components/uart/uart_component_esp8266.cpp @@ -0,0 +1,304 @@ +#ifdef USE_ESP8266 +#include "uart_component_esp8266.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart.arduino_esp8266"; +bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +uint32_t ESP8266UartComponent::get_config() { + uint32_t config = 0; + + if (this->parity_ == UART_CONFIG_PARITY_NONE) { + config |= UART_PARITY_NONE; + } else if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + config |= UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + config |= UART_PARITY_ODD; + } + + switch (this->data_bits_) { + case 5: + config |= UART_NB_BIT_5; + break; + case 6: + config |= UART_NB_BIT_6; + break; + case 7: + config |= UART_NB_BIT_7; + break; + case 8: + config |= UART_NB_BIT_8; + break; + } + + if (this->stop_bits_ == 1) { + config |= UART_NB_STOP_BIT_1; + } else { + config |= UART_NB_STOP_BIT_2; + } + + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + config |= BIT(22); + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + config |= BIT(19); + + return config; +} + +void ESP8266UartComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up UART bus..."); + // Use Arduino HardwareSerial UARTs if all used pins match the ones + // preconfigured by the platform. For example if RX disabled but TX pin + // is 1 we still want to use Serial. + SerialConfig config = static_cast(get_config()); + + if (!ESP8266UartComponent::serial0_in_use && (tx_pin_ == nullptr || tx_pin_->get_pin() == 1) && + (rx_pin_ == nullptr || rx_pin_->get_pin() == 3) +#ifdef USE_LOGGER + // we will use UART0 if logger isn't using it in swapped mode + && (logger::global_logger->get_hw_serial() == nullptr || + logger::global_logger->get_uart() != logger::UART_SELECTION_UART0_SWAP) +#endif + ) { + this->hw_serial_ = &Serial; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + ESP8266UartComponent::serial0_in_use = true; + } else if (!ESP8266UartComponent::serial0_in_use && (tx_pin_ == nullptr || tx_pin_->get_pin() == 15) && + (rx_pin_ == nullptr || rx_pin_->get_pin() == 13) +#ifdef USE_LOGGER + // we will use UART0 swapped if logger isn't using it in regular mode + && (logger::global_logger->get_hw_serial() == nullptr || + logger::global_logger->get_uart() != logger::UART_SELECTION_UART0) +#endif + ) { + this->hw_serial_ = &Serial; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + this->hw_serial_->swap(); + ESP8266UartComponent::serial0_in_use = true; + } else if ((tx_pin_ == nullptr || tx_pin_->get_pin() == 2) && (rx_pin_ == nullptr || rx_pin_->get_pin() == 8)) { + this->hw_serial_ = &Serial1; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + } else { + this->sw_serial_ = new ESP8266SoftwareSerial(); // NOLINT + this->sw_serial_->setup(tx_pin_, rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, + this->rx_buffer_size_); + } +} + +void ESP8266UartComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus:"); + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); // NOLINT + } + ESP_LOGCONFIG(TAG, " Baud Rate: %u baud", this->baud_rate_); + ESP_LOGCONFIG(TAG, " Data Bits: %u", this->data_bits_); + ESP_LOGCONFIG(TAG, " Parity: %s", LOG_STR_ARG(parity_to_str(this->parity_))); + ESP_LOGCONFIG(TAG, " Stop bits: %u", this->stop_bits_); + if (this->hw_serial_ != nullptr) { + ESP_LOGCONFIG(TAG, " Using hardware serial interface."); + } else { + ESP_LOGCONFIG(TAG, " Using software serial"); + } + this->check_logger_conflict(); +} + +void ESP8266UartComponent::check_logger_conflict() { +#ifdef USE_LOGGER + if (this->hw_serial_ == nullptr || logger::global_logger->get_baud_rate() == 0) { + return; + } + + if (this->hw_serial_ == logger::global_logger->get_hw_serial()) { + ESP_LOGW(TAG, " You're using the same serial port for logging and the UART component. Please " + "disable logging over the serial port by setting logger->baud_rate to 0."); + } +#endif +} + +void ESP8266UartComponent::write_array(const uint8_t *data, size_t len) { + if (this->hw_serial_ != nullptr) { + this->hw_serial_->write(data, len); + } else { + for (size_t i = 0; i < len; i++) + this->sw_serial_->write_byte(data[i]); + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} +bool ESP8266UartComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + if (this->hw_serial_ != nullptr) { + *data = this->hw_serial_->peek(); + } else { + *data = this->sw_serial_->peek_byte(); + } + return true; +} +bool ESP8266UartComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + if (this->hw_serial_ != nullptr) { + this->hw_serial_->readBytes(data, len); + } else { + for (size_t i = 0; i < len; i++) + data[i] = this->sw_serial_->read_byte(); + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} +int ESP8266UartComponent::available() { + if (this->hw_serial_ != nullptr) { + return this->hw_serial_->available(); + } else { + return this->sw_serial_->available(); + } +} +void ESP8266UartComponent::flush() { + ESP_LOGVV(TAG, " Flushing..."); + if (this->hw_serial_ != nullptr) { + this->hw_serial_->flush(); + } else { + this->sw_serial_->flush(); + } +} +void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, + uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, + size_t rx_buffer_size) { + this->bit_time_ = F_CPU / baud_rate; + this->rx_buffer_size_ = rx_buffer_size; + this->stop_bits_ = stop_bits; + this->data_bits_ = data_bits; + this->parity_ = parity; + if (tx_pin != nullptr) { + gpio_tx_pin_ = tx_pin; + gpio_tx_pin_->setup(); + tx_pin_ = gpio_tx_pin_->to_isr(); + tx_pin_.digital_write(true); + } + if (rx_pin != nullptr) { + gpio_rx_pin_ = rx_pin; + gpio_rx_pin_->setup(); + rx_pin_ = gpio_rx_pin_->to_isr(); + rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } +} +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { + uint32_t wait = arg->bit_time_ + arg->bit_time_ / 3 - 500; + const uint32_t start = arch_get_cpu_cycle_count(); + uint8_t rec = 0; + // Manually unroll the loop + for (int i = 0; i < arg->data_bits_; i++) + rec |= arg->read_bit_(&wait, start) << i; + + /* If parity is enabled, just read it and ignore it. */ + /* TODO: Should we check parity? Or is it too slow for nothing added..*/ + if (arg->parity_ == UART_CONFIG_PARITY_EVEN || arg->parity_ == UART_CONFIG_PARITY_ODD) + arg->read_bit_(&wait, start); + + // Stop bit + arg->wait_(&wait, start); + if (arg->stop_bits_ == 2) + arg->wait_(&wait, start); + + arg->rx_buffer_[arg->rx_in_pos_] = rec; + arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + // Clear RX pin so that the interrupt doesn't re-trigger right away again. + arg->rx_pin_.clear_interrupt(); +} +void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { + if (this->gpio_tx_pin_ == nullptr) { + ESP_LOGE(TAG, "UART doesn't have TX pins set!"); + return; + } + bool parity_bit = false; + bool need_parity_bit = true; + if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + parity_bit = false; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + parity_bit = true; + } else { + need_parity_bit = false; + } + + { + InterruptLock lock; + uint32_t wait = this->bit_time_; + const uint32_t start = arch_get_cpu_cycle_count(); + // Start bit + this->write_bit_(false, &wait, start); + for (int i = 0; i < this->data_bits_; i++) { + bool bit = data & (1 << i); + this->write_bit_(bit, &wait, start); + if (need_parity_bit) + parity_bit ^= bit; + } + if (need_parity_bit) + this->write_bit_(parity_bit, &wait, start); + // Stop bit + this->write_bit_(true, &wait, start); + if (this->stop_bits_ == 2) + this->wait_(&wait, start); + } +} +void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start) { + while (arch_get_cpu_cycle_count() - start < *wait) + ; + *wait += this->bit_time_; +} +bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start) { + this->wait_(wait, start); + return this->rx_pin_.digital_read(); +} +void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start) { + this->tx_pin_.digital_write(bit); + this->wait_(wait, start); +} +uint8_t ESP8266SoftwareSerial::read_byte() { + if (this->rx_in_pos_ == this->rx_out_pos_) + return 0; + uint8_t data = this->rx_buffer_[this->rx_out_pos_]; + this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; + return data; +} +uint8_t ESP8266SoftwareSerial::peek_byte() { + if (this->rx_in_pos_ == this->rx_out_pos_) + return 0; + return this->rx_buffer_[this->rx_out_pos_]; +} +void ESP8266SoftwareSerial::flush() { + // Flush is a NO-OP with software serial, all bytes are written immediately. +} +int ESP8266SoftwareSerial::available() { + int avail = int(this->rx_in_pos_) - int(this->rx_out_pos_); + if (avail < 0) + return avail + this->rx_buffer_size_; + return avail; +} + +} // namespace uart +} // namespace esphome +#endif // USE_ESP8266 diff --git a/components/uart/uart_component_esp8266.h b/components/uart/uart_component_esp8266.h new file mode 100644 index 0000000..eed14f3 --- /dev/null +++ b/components/uart/uart_component_esp8266.h @@ -0,0 +1,79 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +class ESP8266SoftwareSerial { + public: + void setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, + uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size); + + uint8_t read_byte(); + uint8_t peek_byte(); + + void flush(); + + void write_byte(uint8_t data); + + int available(); + + protected: + static void gpio_intr(ESP8266SoftwareSerial *arg); + + void wait_(uint32_t *wait, const uint32_t &start); + bool read_bit_(uint32_t *wait, const uint32_t &start); + void write_bit_(bool bit, uint32_t *wait, const uint32_t &start); + + uint32_t bit_time_{0}; + uint8_t *rx_buffer_{nullptr}; + size_t rx_buffer_size_; + volatile size_t rx_in_pos_{0}; + size_t rx_out_pos_{0}; + uint8_t stop_bits_; + uint8_t data_bits_; + UARTParityOptions parity_; + InternalGPIOPin *gpio_tx_pin_{nullptr}; + ISRInternalGPIOPin tx_pin_; + InternalGPIOPin *gpio_rx_pin_{nullptr}; + ISRInternalGPIOPin rx_pin_; +}; + +class ESP8266UartComponent : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + int available() override; + void flush() override; + + uint32_t get_config(); + + protected: + void check_logger_conflict() override; + + HardwareSerial *hw_serial_{nullptr}; + ESP8266SoftwareSerial *sw_serial_{nullptr}; + + private: + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/components/uart/uart_component_esp_idf.cpp b/components/uart/uart_component_esp_idf.cpp new file mode 100644 index 0000000..80255cc --- /dev/null +++ b/components/uart/uart_component_esp_idf.cpp @@ -0,0 +1,206 @@ +#ifdef USE_ESP_IDF + +#include "uart_component_esp_idf.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome { +namespace uart { +static const char *const TAG = "uart.idf"; + +uart_config_t IDFUARTComponent::get_config_() { + uart_parity_t parity = UART_PARITY_DISABLE; + if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + parity = UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + parity = UART_PARITY_ODD; + } + + uart_word_length_t data_bits; + switch (this->data_bits_) { + case 5: + data_bits = UART_DATA_5_BITS; + break; + case 6: + data_bits = UART_DATA_6_BITS; + break; + case 7: + data_bits = UART_DATA_7_BITS; + break; + case 8: + data_bits = UART_DATA_8_BITS; + break; + default: + data_bits = UART_DATA_BITS_MAX; + break; + } + + uart_config_t uart_config; + uart_config.baud_rate = this->baud_rate_; + uart_config.data_bits = data_bits; + uart_config.parity = parity; + uart_config.stop_bits = this->stop_bits_ == 1 ? UART_STOP_BITS_1 : UART_STOP_BITS_2; + uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + uart_config.source_clk = UART_SCLK_APB; + uart_config.rx_flow_ctrl_thresh = 122; + + return uart_config; +} + +void IDFUARTComponent::setup() { + static uint8_t next_uart_num = 0; +#ifdef USE_LOGGER + if (logger::global_logger->get_uart_num() == next_uart_num) + next_uart_num++; +#endif + if (next_uart_num >= UART_NUM_MAX) { + ESP_LOGW(TAG, "Maximum number of UART components created already."); + this->mark_failed(); + return; + } + this->uart_num_ = next_uart_num++; + ESP_LOGCONFIG(TAG, "Setting up UART %u...", this->uart_num_); + + this->lock_ = xSemaphoreCreateMutex(); + + xSemaphoreTake(this->lock_, portMAX_DELAY); + + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = uart_driver_install(this->uart_num_, this->rx_buffer_size_, 0, 0, nullptr, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + + err = uart_set_pin(this->uart_num_, tx, rx, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_pin failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + invert |= UART_SIGNAL_TXD_INV; + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + invert |= UART_SIGNAL_RXD_INV; + + err = uart_set_line_inverse(this->uart_num_, invert); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + xSemaphoreGive(this->lock_); +} + +void IDFUARTComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus:"); + ESP_LOGCONFIG(TAG, " Number: %u", this->uart_num_); + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + } + ESP_LOGCONFIG(TAG, " Baud Rate: %u baud", this->baud_rate_); + ESP_LOGCONFIG(TAG, " Data Bits: %u", this->data_bits_); + ESP_LOGCONFIG(TAG, " Parity: %s", LOG_STR_ARG(parity_to_str(this->parity_))); + ESP_LOGCONFIG(TAG, " Stop bits: %u", this->stop_bits_); + this->check_logger_conflict(); +} + +void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { + xSemaphoreTake(this->lock_, portMAX_DELAY); + uart_write_bytes(this->uart_num_, data, len); + xSemaphoreGive(this->lock_); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} + +bool IDFUARTComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + xSemaphoreTake(this->lock_, portMAX_DELAY); + if (this->has_peek_) { + *data = this->peek_byte_; + } else { + int len = uart_read_bytes(this->uart_num_, data, 1, 20 / portTICK_RATE_MS); + if (len == 0) { + *data = 0; + } else { + this->has_peek_ = true; + this->peek_byte_ = *data; + } + } + xSemaphoreGive(this->lock_); + return true; +} + +bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { + size_t length_to_read = len; + if (!this->check_read_timeout_(len)) + return false; + xSemaphoreTake(this->lock_, portMAX_DELAY); + if (this->has_peek_) { + length_to_read--; + *data = this->peek_byte_; + data++; + this->has_peek_ = false; + } + if (length_to_read > 0) + uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_RATE_MS); + xSemaphoreGive(this->lock_); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} + +int IDFUARTComponent::available() { + size_t available; + + xSemaphoreTake(this->lock_, portMAX_DELAY); + uart_get_buffered_data_len(this->uart_num_, &available); + if (this->has_peek_) + available++; + xSemaphoreGive(this->lock_); + + return available; +} + +void IDFUARTComponent::flush() { + ESP_LOGVV(TAG, " Flushing..."); + xSemaphoreTake(this->lock_, portMAX_DELAY); + uart_wait_tx_done(this->uart_num_, portMAX_DELAY); + xSemaphoreGive(this->lock_); +} + +void IDFUARTComponent::check_logger_conflict() {} + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP32 diff --git a/components/uart/uart_component_esp_idf.h b/components/uart/uart_component_esp_idf.h new file mode 100644 index 0000000..27fb80d --- /dev/null +++ b/components/uart/uart_component_esp_idf.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef USE_ESP_IDF + +#include +#include "esphome/core/component.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +class IDFUARTComponent : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + int available() override; + void flush() override; + + protected: + void check_logger_conflict() override; + uart_port_t uart_num_; + uart_config_t get_config_(); + SemaphoreHandle_t lock_; + + bool has_peek_{false}; + uint8_t peek_byte_; +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_ESP_IDF diff --git a/components/uart/uart_component_rp2040.cpp b/components/uart/uart_component_rp2040.cpp new file mode 100644 index 0000000..e2c4708 --- /dev/null +++ b/components/uart/uart_component_rp2040.cpp @@ -0,0 +1,184 @@ +#ifdef USE_RP2040 +#include "uart_component_rp2040.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart.arduino_rp2040"; + +uint16_t RP2040UartComponent::get_config() { + uint16_t config = 0; + + if (this->parity_ == UART_CONFIG_PARITY_NONE) { + config |= UART_PARITY_NONE; + } else if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + config |= UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + config |= UART_PARITY_ODD; + } + + switch (this->data_bits_) { + case 5: + config |= SERIAL_DATA_5; + break; + case 6: + config |= SERIAL_DATA_6; + break; + case 7: + config |= SERIAL_DATA_7; + break; + case 8: + config |= SERIAL_DATA_8; + break; + } + + if (this->stop_bits_ == 1) { + config |= SERIAL_STOP_BIT_1; + } else { + config |= SERIAL_STOP_BIT_2; + } + + return config; +} + +void RP2040UartComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up UART bus..."); + + uint16_t config = get_config(); + + constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28}); + constexpr uint32_t valid_tx_uart_1 = __bitset({4, 8, 20, 24}); + + constexpr uint32_t valid_rx_uart_0 = __bitset({1, 13, 17, 29}); + constexpr uint32_t valid_rx_uart_1 = __bitset({5, 9, 21, 25}); + + int8_t tx_hw = -1; + int8_t rx_hw = -1; + + if (this->tx_pin_ != nullptr) { + if (this->tx_pin_->is_inverted()) { + ESP_LOGD(TAG, "An inverted TX pin %u can only be used with SerialPIO", this->tx_pin_->get_pin()); + } else { + if (((1 << this->tx_pin_->get_pin()) & valid_tx_uart_0) != 0) { + tx_hw = 0; + } else if (((1 << this->tx_pin_->get_pin()) & valid_tx_uart_1) != 0) { + tx_hw = 1; + } else { + ESP_LOGD(TAG, "TX pin %u can only be used with SerialPIO", this->tx_pin_->get_pin()); + } + } + } + + if (this->rx_pin_ != nullptr) { + if (this->rx_pin_->is_inverted()) { + ESP_LOGD(TAG, "An inverted RX pin %u can only be used with SerialPIO", this->rx_pin_->get_pin()); + } else { + if (((1 << this->rx_pin_->get_pin()) & valid_rx_uart_0) != 0) { + rx_hw = 0; + } else if (((1 << this->rx_pin_->get_pin()) & valid_rx_uart_1) != 0) { + rx_hw = 1; + } else { + ESP_LOGD(TAG, "RX pin %u can only be used with SerialPIO", this->rx_pin_->get_pin()); + } + } + } + +#ifdef USE_LOGGER + if (tx_hw == rx_hw && logger::global_logger->get_uart() == tx_hw) { + ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", tx_hw); + tx_hw = -1; + rx_hw = -1; + } +#endif + + if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) { + ESP_LOGV(TAG, "Using SerialPIO"); + pin_size_t tx = this->tx_pin_ == nullptr ? SerialPIO::NOPIN : this->tx_pin_->get_pin(); + pin_size_t rx = this->rx_pin_ == nullptr ? SerialPIO::NOPIN : this->rx_pin_->get_pin(); + auto *serial = new SerialPIO(tx, rx, this->rx_buffer_size_); // NOLINT(cppcoreguidelines-owning-memory) + serial->begin(this->baud_rate_, config); + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + gpio_set_outover(tx, GPIO_OVERRIDE_INVERT); + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + gpio_set_inover(rx, GPIO_OVERRIDE_INVERT); + this->serial_ = serial; + } else { + ESP_LOGV(TAG, "Using Hardware Serial"); + SerialUART *serial; + if (tx_hw == 0) { + serial = &Serial1; + } else { + serial = &Serial2; + } + serial->setTX(this->tx_pin_->get_pin()); + serial->setRX(this->rx_pin_->get_pin()); + serial->setFIFOSize(this->rx_buffer_size_); + serial->begin(this->baud_rate_, config); + this->serial_ = serial; + this->hw_serial_ = true; + } +} + +void RP2040UartComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus:"); + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + } + ESP_LOGCONFIG(TAG, " Baud Rate: %u baud", this->baud_rate_); + ESP_LOGCONFIG(TAG, " Data Bits: %u", this->data_bits_); + ESP_LOGCONFIG(TAG, " Parity: %s", LOG_STR_ARG(parity_to_str(this->parity_))); + ESP_LOGCONFIG(TAG, " Stop bits: %u", this->stop_bits_); + if (this->hw_serial_) { + ESP_LOGCONFIG(TAG, " Using hardware serial"); + } else { + ESP_LOGCONFIG(TAG, " Using SerialPIO"); + } +} + +void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { + this->serial_->write(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} +bool RP2040UartComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + *data = this->serial_->peek(); + return true; +} +bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + this->serial_->readBytes(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} +int RP2040UartComponent::available() { return this->serial_->available(); } +void RP2040UartComponent::flush() { + ESP_LOGVV(TAG, " Flushing..."); + this->serial_->flush(); +} + +} // namespace uart +} // namespace esphome + +#endif // USE_RP2040 diff --git a/components/uart/uart_component_rp2040.h b/components/uart/uart_component_rp2040.h new file mode 100644 index 0000000..163315d --- /dev/null +++ b/components/uart/uart_component_rp2040.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef USE_RP2040 + +#include +#include + +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +class RP2040UartComponent : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + int available() override; + void flush() override; + + uint16_t get_config(); + + protected: + void check_logger_conflict() override {} + bool hw_serial_{false}; + + HardwareSerial *serial_{nullptr}; +}; + +} // namespace uart +} // namespace esphome + +#endif // USE_RP2040 diff --git a/components/uart/uart_debugger.cpp b/components/uart/uart_debugger.cpp new file mode 100644 index 0000000..e2d92ea --- /dev/null +++ b/components/uart/uart_debugger.cpp @@ -0,0 +1,202 @@ +#include "esphome/core/defines.h" +#ifdef USE_UART_DEBUGGER + +#include +#include "uart_debugger.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace uart { + +static const char *const TAG = "uart_debug"; + +UARTDebugger::UARTDebugger(UARTComponent *parent) { + parent->add_debug_callback([this](UARTDirection direction, uint8_t byte) { + if (!this->is_my_direction_(direction) || this->is_recursive_()) { + return; + } + this->trigger_after_direction_change_(direction); + this->store_byte_(direction, byte); + this->trigger_after_delimiter_(byte); + this->trigger_after_bytes_(); + }); +} + +void UARTDebugger::loop() { this->trigger_after_timeout_(); } + +bool UARTDebugger::is_my_direction_(UARTDirection direction) { + return this->for_direction_ == UART_DIRECTION_BOTH || this->for_direction_ == direction; +} + +bool UARTDebugger::is_recursive_() { return this->is_triggering_; } + +void UARTDebugger::trigger_after_direction_change_(UARTDirection direction) { + if (this->has_buffered_bytes_() && this->for_direction_ == UART_DIRECTION_BOTH && + this->last_direction_ != direction) { + this->fire_trigger_(); + } +} + +void UARTDebugger::store_byte_(UARTDirection direction, uint8_t byte) { + this->bytes_.push_back(byte); + this->last_direction_ = direction; + this->last_time_ = millis(); +} + +void UARTDebugger::trigger_after_delimiter_(uint8_t byte) { + if (this->after_delimiter_.empty() || !this->has_buffered_bytes_()) { + return; + } + if (this->after_delimiter_[this->after_delimiter_pos_] != byte) { + this->after_delimiter_pos_ = 0; + return; + } + this->after_delimiter_pos_++; + if (this->after_delimiter_pos_ == this->after_delimiter_.size()) { + this->fire_trigger_(); + this->after_delimiter_pos_ = 0; + } +} + +void UARTDebugger::trigger_after_bytes_() { + if (this->has_buffered_bytes_() && this->after_bytes_ > 0 && this->bytes_.size() >= this->after_bytes_) { + this->fire_trigger_(); + } +} + +void UARTDebugger::trigger_after_timeout_() { + if (this->has_buffered_bytes_() && this->after_timeout_ > 0 && millis() - this->last_time_ >= this->after_timeout_) { + this->fire_trigger_(); + } +} + +bool UARTDebugger::has_buffered_bytes_() { return !this->bytes_.empty(); } + +void UARTDebugger::fire_trigger_() { + this->is_triggering_ = true; + trigger(this->last_direction_, this->bytes_); + this->bytes_.clear(); + this->is_triggering_ = false; +} + +void UARTDummyReceiver::loop() { + // Reading up to a limited number of bytes, to make sure that this loop() + // won't lock up the system on a continuous incoming stream of bytes. + uint8_t data; + int count = 50; + while (this->available() && count--) { + this->read_byte(&data); + } +} + +// In the upcoming log functions, a delay was added after all log calls. +// This is done to allow the system to ship the log lines via the API +// TCP connection(s). Without these delays, debug log lines could go +// missing when UART devices block the main loop for too long. + +void UARTDebug::log_hex(UARTDirection direction, std::vector bytes, uint8_t separator) { + std::string res; + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + size_t len = bytes.size(); + char buf[5]; + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + sprintf(buf, "%02X", bytes[i]); + res += buf; + } + ESP_LOGD(TAG, "%s", res.c_str()); + delay(10); +} + +void UARTDebug::log_string(UARTDirection direction, std::vector bytes) { + std::string res; + if (direction == UART_DIRECTION_RX) { + res += "<<< \""; + } else { + res += ">>> \""; + } + size_t len = bytes.size(); + char buf[5]; + for (size_t i = 0; i < len; i++) { + if (bytes[i] == 7) { + res += "\\a"; + } else if (bytes[i] == 8) { + res += "\\b"; + } else if (bytes[i] == 9) { + res += "\\t"; + } else if (bytes[i] == 10) { + res += "\\n"; + } else if (bytes[i] == 11) { + res += "\\v"; + } else if (bytes[i] == 12) { + res += "\\f"; + } else if (bytes[i] == 13) { + res += "\\r"; + } else if (bytes[i] == 27) { + res += "\\e"; + } else if (bytes[i] == 34) { + res += "\\\""; + } else if (bytes[i] == 39) { + res += "\\'"; + } else if (bytes[i] == 92) { + res += "\\\\"; + } else if (bytes[i] < 32 || bytes[i] > 127) { + sprintf(buf, "\\x%02X", bytes[i]); + res += buf; + } else { + res += bytes[i]; + } + } + res += '"'; + ESP_LOGD(TAG, "%s", res.c_str()); + delay(10); +} + +void UARTDebug::log_int(UARTDirection direction, std::vector bytes, uint8_t separator) { + std::string res; + size_t len = bytes.size(); + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + res += to_string(bytes[i]); + } + ESP_LOGD(TAG, "%s", res.c_str()); + delay(10); +} + +void UARTDebug::log_binary(UARTDirection direction, std::vector bytes, uint8_t separator) { + std::string res; + size_t len = bytes.size(); + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + char buf[20]; + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + sprintf(buf, "0b" BYTE_TO_BINARY_PATTERN " (0x%02X)", BYTE_TO_BINARY(bytes[i]), bytes[i]); + res += buf; + } + ESP_LOGD(TAG, "%s", res.c_str()); + delay(10); +} + +} // namespace uart +} // namespace esphome +#endif diff --git a/components/uart/uart_debugger.h b/components/uart/uart_debugger.h new file mode 100644 index 0000000..4f9b6d0 --- /dev/null +++ b/components/uart/uart_debugger.h @@ -0,0 +1,101 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_UART_DEBUGGER + +#include +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "uart.h" +#include "uart_component.h" + +namespace esphome { +namespace uart { + +/// The UARTDebugger class adds debugging support to a UART bus. +/// +/// It accumulates bytes that travel over the UART bus and triggers one or +/// more actions that can log the data at an appropriate time. What +/// 'appropriate time' means exactly, is determined by a number of +/// configurable constraints. E.g. when a given number of bytes is gathered +/// and/or when no more data has been seen for a given time interval. +class UARTDebugger : public Component, public Trigger> { + public: + explicit UARTDebugger(UARTComponent *parent); + void loop() override; + + /// Set the direction in which to inspect the bytes: incoming, outgoing + /// or both. When debugging in both directions, logging will be triggered + /// when the direction of the data stream changes. + void set_direction(UARTDirection direction) { this->for_direction_ = direction; } + + /// Set the maximum number of bytes to accumulate. When the number of bytes + /// is reached, logging will be triggered. + void set_after_bytes(size_t size) { this->after_bytes_ = size; } + + /// Set a timeout for the data stream. When no new bytes are seen during + /// this timeout, logging will be triggered. + void set_after_timeout(uint32_t timeout) { this->after_timeout_ = timeout; } + + /// Add a delimiter byte. This can be called multiple times to setup a + /// multi-byte delimiter (a typical example would be '\r\n'). + /// When the constructed byte sequence is found in the data stream, + /// logging will be triggered. + void add_delimiter_byte(uint8_t byte) { this->after_delimiter_.push_back(byte); } + + protected: + UARTDirection for_direction_; + UARTDirection last_direction_{}; + std::vector bytes_{}; + size_t after_bytes_; + uint32_t after_timeout_; + uint32_t last_time_{}; + std::vector after_delimiter_{}; + size_t after_delimiter_pos_{}; + bool is_triggering_{false}; + + bool is_my_direction_(UARTDirection direction); + bool is_recursive_(); + void store_byte_(UARTDirection direction, uint8_t byte); + void trigger_after_direction_change_(UARTDirection direction); + void trigger_after_delimiter_(uint8_t byte); + void trigger_after_bytes_(); + void trigger_after_timeout_(); + bool has_buffered_bytes_(); + void fire_trigger_(); +}; + +/// This UARTDevice is used by the serial debugger to read data from a +/// serial interface when the 'dummy_receiver' option is enabled. +/// The data are not stored, nor processed. This is most useful when the +/// debugger is used to reverse engineer a serial protocol, for which no +/// specific UARTDevice implementation exists (yet), but for which the +/// incoming bytes must be read to drive the debugger. +class UARTDummyReceiver : public Component, public UARTDevice { + public: + UARTDummyReceiver(UARTComponent *parent) : UARTDevice(parent) {} + void loop() override; +}; + +/// This class contains some static methods, that can be used to easily +/// create a logging action for the debugger. +class UARTDebug { + public: + /// Log the bytes as hex values, separated by the provided separator + /// character. + static void log_hex(UARTDirection direction, std::vector bytes, uint8_t separator); + + /// Log the bytes as string values, escaping unprintable characters. + static void log_string(UARTDirection direction, std::vector bytes); + + /// Log the bytes as integer values, separated by the provided separator + /// character. + static void log_int(UARTDirection direction, std::vector bytes, uint8_t separator); + + /// Log the bytes as ' ()' values, separated by the provided + /// separator. + static void log_binary(UARTDirection direction, std::vector bytes, uint8_t separator); +}; + +} // namespace uart +} // namespace esphome +#endif diff --git a/truma.yaml b/truma.yaml new file mode 100644 index 0000000..c879af7 --- /dev/null +++ b/truma.yaml @@ -0,0 +1,253 @@ +esphome: + name: "esphome-truma-all-config" + + on_boot: + then: + # read time from external source (connected via I2C) + - ds1307.read_time + # wait 90 seconds for Truma CP Plus connection to init + - delay: 90 seconds + # update CP Plus clock + - truma_inetbox.clock.set + +external_components: + - source: github://Fabian-Schmidt/esphome-truma_inetbox + +esp32: + board: mhetesp32devkit + framework: + type: arduino + +i2c: + sda: 14 + scl: 27 + scan: false + id: i2c_bus_a + +time: + - platform: ds1307 + update_interval: never + - platform: sntp + on_time_sync: + - ds1307.write_time + # wait 90 seconds for Truma CP Plus connection to init + - delay: 90 seconds + - truma_inetbox.clock.set + +uart: + - id: lin_uart_bus + tx_pin: 17 + rx_pin: 16 + baud_rate: 9600 + data_bits: 8 + parity: NONE + stop_bits: 2 + +truma_inetbox: + uart_id: lin_uart_bus + cs_pin: 5 + fault_pin: 18 + # Advanced users can use `on_heater_message` action. The heater data is in the `message` variable. + on_heater_message: + then: + - logger.log: "Message from CP Plus." + - if: + condition: + lambda: return message->operating_status == truma_inetbox::OperatingStatus::OPERATING_STATUS_OFF; + then: + - logger.log: "Heater is off." + +binary_sensor: + - platform: truma_inetbox + name: "CP Plus alive" + type: CP_PLUS_CONNECTED + + - platform: truma_inetbox + name: "Room Heater active" + type: HEATER_ROOM + id: HEATER_ROOM + - platform: truma_inetbox + name: "Water Heater active" + type: HEATER_WATER + id: HEATER_WATER + - platform: truma_inetbox + name: "Heater mode Gas" + type: HEATER_GAS + - platform: truma_inetbox + name: "Heater mode Mix 1" + type: HEATER_MIX_1 + - platform: truma_inetbox + name: "Heater mode Mix 2" + type: HEATER_MIX_2 + - platform: truma_inetbox + name: "Heater mode Elec" + type: HEATER_ELECTRICITY + + - platform: truma_inetbox + name: "Timer active" + type: TIMER_ACTIVE + id: TIMER_ACTIVE + - platform: truma_inetbox + name: "Timer Room Heater active" + type: TIMER_ROOM + - platform: truma_inetbox + name: "Timer Water Heater active" + type: TIMER_WATER + +climate: + - platform: truma_inetbox + name: "Truma Room" + type: ROOM + - platform: truma_inetbox + name: "Truma Water" + type: WATER + +number: + - platform: truma_inetbox + name: "Target Room Temperature" + type: TARGET_ROOM_TEMPERATURE + - platform: truma_inetbox + name: "Target Water Temperature" + type: TARGET_WATER_TEMPERATURE + - platform: truma_inetbox + name: "electric power level" + type: ELECTRIC_POWER_LEVEL + +sensor: + - platform: truma_inetbox + name: "Current Room Temperature" + type: CURRENT_ROOM_TEMPERATURE + - platform: truma_inetbox + name: "Current Water Temperature" + type: CURRENT_WATER_TEMPERATURE + - platform: truma_inetbox + name: "Target Room Temperature" + type: TARGET_ROOM_TEMPERATURE + - platform: truma_inetbox + name: "Target Water Temperature" + type: TARGET_WATER_TEMPERATURE + - platform: truma_inetbox + name: "Heating mode" + type: HEATING_MODE + - platform: truma_inetbox + name: "electric power level" + type: ELECTRIC_POWER_LEVEL + - platform: truma_inetbox + name: "Energy mix" + type: ENERGY_MIX + - platform: truma_inetbox + name: "Operating status" + type: OPERATING_STATUS + +switch: + - platform: template + name: "Activate Room Heater" + lambda: |- + return id(HEATER_ROOM).state; + turn_on_action: + - truma_inetbox.heater.set_target_room_temperature: + # You can use lambda functions + temperature: !lambda |- + return 16; + # Optional set heating mode: `"OFF"`, `ECO`, `HIGH`, `BOOST` + heating_mode: ECO + turn_off_action: + - truma_inetbox.heater.set_target_room_temperature: + # Disable heater by setting temperature to `0`. + temperature: 0 + - platform: template + name: "Activate Water Heater" + lambda: |- + return id(HEATER_WATER).state; + turn_on_action: + - truma_inetbox.heater.set_target_water_temperature: + # Set water temp as number: `0`, `40`, `60`, `80` + temperature: 40 + turn_off_action: + - truma_inetbox.heater.set_target_water_temperature: + # Disable heater by setting temperature to `0`. + temperature: 0 + - platform: template + name: "Activate Water Heater (enum)" + lambda: |- + return id(HEATER_WATER).state; + turn_on_action: + - truma_inetbox.heater.set_target_water_temperature_enum: + # Set water temp as text: `"OFF"`, `ECO`, `HIGH`, `BOOST` + temperature: ECO + turn_off_action: + # You can also use the simplified syntax. + - truma_inetbox.heater.set_target_water_temperature_enum: "OFF" + - platform: template + name: "Active Timer" + lambda: |- + return id(TIMER_ACTIVE).state; + turn_on_action: + - truma_inetbox.timer.activate: + start: 7:00 + stop: 9:30 + # Required: Set room temp to a number between 5 and 30 + room_temperature: 13 + # Optional: Set heating mode: `"OFF"`, `ECO`, `HIGH`, `BOOST` + heating_mode: ECO + # Optional: Set water temp as number: `0`, `40`, `60`, `80` + water_temperature: 0 + # Optional: Set energy mix to: `GAS`, `MIX`, `ELECTRICITY` + energy_mix: GAS + # Optional: Set electricity level to `0`, `900`, `1800` + watt: 0 + + turn_off_action: + # You can also use the simplified syntax. + - truma_inetbox.timer.disable + +button: + - platform: template + name: "Energy mix GAS only" + on_press: + - truma_inetbox.heater.set_energy_mix: + # Set energy mix to: `GAS`, `MIX`, `ELECTRICITY` + energy_mix: GAS + - platform: template + name: "Energy mix MIX 1" + on_press: + - truma_inetbox.heater.set_energy_mix: + energy_mix: MIX + # Set electricity level to `0`, `900`, `1800` + watt: 900W + - platform: template + name: "Energy mix MIX 2" + on_press: + - truma_inetbox.heater.set_energy_mix: + energy_mix: MIX + watt: 1800 + - platform: template + name: "Energy mix ELECTRICITY only" + on_press: + - truma_inetbox.heater.set_energy_mix: + energy_mix: ELECTRICITY + watt: 1800W + - platform: template + name: "Set electric power level to 0 Watt" + on_press: + - truma_inetbox.heater.set_electric_power_level: 0 + - platform: template + name: "Set electric power level to 900 Watt" + on_press: + - truma_inetbox.heater.set_electric_power_level: 900 + - platform: template + name: "Set electric power level to 1800 Watt" + on_press: + - truma_inetbox.heater.set_electric_power_level: 1800 + +wifi: + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: "Fallback Hotspot" + password: "Sw9g4XJtoyrn" + +web_server: + port: 80 + local: true + version: 2 + include_internal: true \ No newline at end of file