From 2b3fc3dd66cfc80ce8718c1d6a4a81445b0b1664 Mon Sep 17 00:00:00 2001 From: Mikhail Diatchenko Date: Sat, 20 Aug 2022 14:48:24 +1200 Subject: [PATCH] Added working version --- .clang-format | 137 ++++++ README.md | 31 ++ components/tcc_link/__init__.py | 0 components/tcc_link/automation.h | 26 ++ components/tcc_link/climate.py | 108 +++++ components/tcc_link/tcc_link.cpp | 687 +++++++++++++++++++++++++++++++ components/tcc_link/tcc_link.h | 331 +++++++++++++++ example.yaml | 38 ++ format.sh | 3 + 9 files changed, 1361 insertions(+) create mode 100644 .clang-format create mode 100644 components/tcc_link/__init__.py create mode 100644 components/tcc_link/automation.h create mode 100644 components/tcc_link/climate.py create mode 100644 components/tcc_link/tcc_link.cpp create mode 100644 components/tcc_link/tcc_link.h create mode 100644 example.yaml create mode 100755 format.sh 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/README.md b/README.md index 6cc6648..1a5daf4 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,34 @@ ESPHome component to integrate with Toshiba Air Conditioners via TCC-Link protocol (AB line). Requires reader & writer circuit for the AB line: https://github.com/issalig/toshiba_air_cond + +## Install + +```yaml +external_components: + - source: + type: git + url: https://github.com/muxa/esphome-tcc-link + +``` + +## Usage + +```yaml +uart: + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: 2400 + parity: EVEN + +climate: + - platform: tcc_link + name: "Toshiba AC" + id: toshiba_ac + connected: + name: "Toshiba AC Connected" + failed_crcs: + name: "Toshiba AC Failed CRCs" + vent: + name: "Toshiba AC Vent Switch" +``` diff --git a/components/tcc_link/__init__.py b/components/tcc_link/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/tcc_link/automation.h b/components/tcc_link/automation.h new file mode 100644 index 0000000..9387363 --- /dev/null +++ b/components/tcc_link/automation.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "tcc_link.h" + +namespace esphome +{ + namespace tcc_link + { + + class TccLinkOnDataReceivedTrigger : public Trigger> + { + public: + TccLinkOnDataReceivedTrigger(TccLinkClimate *climate) + { + climate->add_on_data_received_callback( + [this](const struct DataFrame *frame) + { + this->trigger(frame->get_data()); + }); + } + }; + + } // namespace tcc_link +} // namespace esphome \ No newline at end of file diff --git a/components/tcc_link/climate.py b/components/tcc_link/climate.py new file mode 100644 index 0000000..bc13aa7 --- /dev/null +++ b/components/tcc_link/climate.py @@ -0,0 +1,108 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.components import climate, uart, binary_sensor, sensor, switch, text_sensor, template +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_HARDWARE_UART, + CONF_BAUD_RATE, + CONF_UPDATE_INTERVAL, + CONF_MODE, + CONF_FAN_MODE, + CONF_SWING_MODE, + CONF_TRIGGER_ID, + DEVICE_CLASS_CONNECTIVITY, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, +) + +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["climate", "binary_sensor", "sensor", "switch"] +CODEOWNERS = ["@muxa"] + +tcc_link_ns = cg.esphome_ns.namespace("tcc_link") + +CONF_CONNECTED = "connected" +CONF_VENT = "vent" +CONF_FAILED_CRCS = "failed_crcs" + +CONF_ON_DATA_RECEIVED = "on_data_received" + +TccLinkClimate = tcc_link_ns.class_( + "TccLinkClimate", climate.Climate, uart.UARTDevice, cg.Component +) + +TccLinkVentSwitch = tcc_link_ns.class_( + "TccLinkVentSwitch", switch.Switch, cg.Component +) + +TccLinkOnDataReceivedTrigger = tcc_link_ns.class_( + "TccLinkOnDataReceivedTrigger", automation.Trigger.template() +) + +CONFIG_SCHEMA = climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TccLinkClimate), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class = DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VENT): cv.maybe_simple_value( + switch.SWITCH_SCHEMA.extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TccLinkVentSwitch), + } + ) + ), + key=CONF_NAME, + ), + cv.Optional(CONF_FAILED_CRCS): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ON_DATA_RECEIVED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + TccLinkOnDataReceivedTrigger + ), + } + ), + } +).extend(uart.UART_DEVICE_SCHEMA).extend(cv.COMPONENT_SCHEMA) + +def validate_uart(config): + uart.final_validate_device_schema( + "tcc_link", baud_rate=2400, require_rx=True, require_tx=False + )(config) + + +FINAL_VALIDATE_SCHEMA = validate_uart + +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 uart.register_uart_device(var, config) + + if CONF_CONNECTED in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_CONNECTED]) + cg.add(var.set_connected_binary_sensor(sens)) + + if CONF_FAILED_CRCS in config: + sens = await sensor.new_sensor(config[CONF_FAILED_CRCS]) + cg.add(var.set_failed_crcs_sensor(sens)) + + if CONF_VENT in config: + sw = await switch.new_switch(config[CONF_VENT], var) + cg.add(var.set_vent_switch(sw)) + + if CONF_ON_DATA_RECEIVED in config: + for on_data_received in config.get(CONF_ON_DATA_RECEIVED, []): + data_trigger = cg.new_Pvariable(on_data_received[CONF_TRIGGER_ID], var) + await automation.build_automation( + data_trigger, [(cg.std_vector.template(cg.uint8), "x")], on_data_received + ) diff --git a/components/tcc_link/tcc_link.cpp b/components/tcc_link/tcc_link.cpp new file mode 100644 index 0000000..8996b2e --- /dev/null +++ b/components/tcc_link/tcc_link.cpp @@ -0,0 +1,687 @@ +#include "tcc_link.h" + +namespace esphome { +namespace tcc_link { + +static const char *const TAG = "tcc_link.climate"; + +const LogString *opcode_to_string(uint8_t opcode) { + switch (opcode) { + case OPCODE_PING: + return LOG_STR("OPCODE_PING"); + case OPCODE_PARAMETER: + return LOG_STR("OPCODE_PARAMETER"); + case OPCODE_ERROR_HISTORY: + return LOG_STR("OPCODE_ERROR_HISTORY"); + case OPCODE_SENSOR_QUERY: + return LOG_STR("OPCODE_SENSOR_QUERY"); + case OPCODE_ACK: + return LOG_STR("OPCODE_ACK"); + case OPCODE_SENSOR_VALUE: + return LOG_STR("OPCODE_SENSOR_VALUE"); + case OPCODE_STATUS: + return LOG_STR("OPCODE_STATUS"); + case OPCODE_TEMPERATURE: + return LOG_STR("OPCODE_TEMPERATURE"); + case OPCODE_EXTENDED_STATUS: + return LOG_STR("OPCODE_EXTENDED_STATUS"); + default: + // return LOG_STR(str_sprintf("UNKNOWN OPCODE 1: 0x%02x", opcode)); + return LOG_STR("UNKNOWN"); + } +} + +uint8_t temp_celcius_to_payload(float temp_celsius) { + return static_cast(temp_celsius + TEMPERATURE_CONVERSION_OFFSET) * + TEMPERATURE_CONVERSION_RATIO; // temp is +35 in (bit7-bit0)/2 +} + +uint8_t get_heat_cool_bits(uint8_t mode) { + // TODO: figure the bits out: + // https://github.com/issalig/toshiba_air_cond/blob/master/air/toshiba_serial.hpp#L285 + switch (mode) { + case MODE_HEAT: + case MODE_AUTO: + return 0x55; // 0b01010101 // = 0x01 + 0x04 * air->heat + 0x02 * air->cold; + case MODE_COOL: + case MODE_DRY: + case MODE_FAN_ONLY: + return 0x33; // 0b00110011 // = 0x01 + 0x04 * air->heat + 0x02 * air->cold; + } + + return 0; +} + +uint8_t get_fan_bit_mask_for_mode(uint8_t mode) { + switch (mode) { + case MODE_HEAT: + case MODE_AUTO: + return 0b00100000 | 0b00001000; + case MODE_COOL: + case MODE_DRY: + case MODE_FAN_ONLY: + return 0b00010000 | 0b00001000; + } + + return 0; +} + +void write_set_parameter(struct DataFrame *command, uint8_t opcode2, uint8_t payload[], size_t payload_size) { + command->source = TOSHIBA_REMOTE; + command->dest = TOSHIBA_MASTER; + command->opcode1 = OPCODE_PARAMETER; + command->data_length = SET_PARAMETER_PAYLOAD_HEADER_SIZE + payload_size; + command->data[0] = COMMAND_MODE_READ; + command->data[1] = opcode2; + + for (size_t i = 0; i < payload_size; i++) { + command->data[SET_PARAMETER_PAYLOAD_HEADER_SIZE + i] = payload[i]; + } + + command->data[SET_PARAMETER_PAYLOAD_HEADER_SIZE + payload_size] = command->calculate_crc(); +} + +void write_set_parameter(struct DataFrame *command, uint8_t opcode2, uint8_t single_type_payload) { + uint8_t payload[1] = {single_type_payload}; + write_set_parameter(command, opcode2, payload, 1); +} + +void write_set_parameter_flags(struct DataFrame *command, const struct TccState *state, uint8_t set_flags) { + uint8_t payload[6] = { + static_cast(state->mode | set_flags), + static_cast(state->fan | get_fan_bit_mask_for_mode(state->mode)), + temp_celcius_to_payload(state->target_temp), + EMPTY_DATA, + get_heat_cool_bits(state->mode), + get_heat_cool_bits(state->mode), + }; + write_set_parameter(command, OPCODE2_SET_TEMP_WITH_FAN, payload, sizeof(payload)); +} + +void write_set_parameter_mode(struct DataFrame *command, const struct TccState *state) { + write_set_parameter(command, OPCODE2_SET_MODE, state->mode); +} + +void write_set_parameter_power(struct DataFrame *command, const struct TccState *state) { + write_set_parameter(command, OPCODE2_SET_POWER, state->power | 0b0010); +} + +void write_set_parameter_vent(struct DataFrame *command, const struct TccState *state) { + write_set_parameter(command, OPCODE2_SET_VENT, state->vent); +} + +uint8_t to_tcc_power(const climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_OFF: + return 0; + default: + return 1; + } +} + +uint8_t to_tcc_mode(const climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_OFF: + return 0; + case climate::CLIMATE_MODE_HEAT: + return MODE_HEAT; + case climate::CLIMATE_MODE_COOL: + return MODE_COOL; + case climate::CLIMATE_MODE_FAN_ONLY: + return MODE_FAN_ONLY; + case climate::CLIMATE_MODE_DRY: + return MODE_DRY; + case climate::CLIMATE_MODE_HEAT_COOL: + return MODE_AUTO; + default: + return 0; + } +} + +uint8_t to_tcc_fan(const climate::ClimateFanMode fan) { + switch (fan) { + case climate::CLIMATE_FAN_AUTO: + return FAN_PEED_AUTO; + case climate::CLIMATE_FAN_LOW: + return FAN_PEED_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return FAN_PEED_MED; + case climate::CLIMATE_FAN_HIGH: + return FAN_PEED_HIGH; + default: + return climate::CLIMATE_FAN_OFF; + } +} + +climate::ClimateAction to_climate_action(const struct TccState *state) { + if (state->power == 0) + return climate::CLIMATE_ACTION_OFF; + + switch (state->mode) { + case MODE_HEAT: + case MODE_AUTO: + case MODE_COOL: + if (state->cooling) { + return climate::CLIMATE_ACTION_COOLING; + } else if (state->heating) { + return climate::CLIMATE_ACTION_HEATING; + } + return climate::CLIMATE_ACTION_IDLE; + case MODE_FAN_ONLY: + return climate::CLIMATE_ACTION_FAN; + case MODE_DRY: + return climate::CLIMATE_ACTION_DRYING; + } + + return climate::CLIMATE_ACTION_IDLE; +} + +climate::ClimateMode to_climate_mode(const struct TccState *state) { + if (state->power == 0) + return climate::CLIMATE_MODE_OFF; + + switch (state->mode) { + case MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case MODE_FAN_ONLY: + return climate::CLIMATE_MODE_FAN_ONLY; + case MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case MODE_AUTO: + return climate::CLIMATE_MODE_HEAT_COOL; + } + + return climate::CLIMATE_MODE_OFF; +} + +climate::ClimateFanMode to_climate_fan(const struct TccState *state) { + if (state->power == 0) + return climate::CLIMATE_FAN_OFF; + + switch (state->fan) { + case FAN_PEED_AUTO: + return climate::CLIMATE_FAN_AUTO; + case FAN_PEED_LOW: + return climate::CLIMATE_FAN_LOW; + case FAN_PEED_MED: + return climate::CLIMATE_FAN_MEDIUM; + case FAN_PEED_HIGH: + return climate::CLIMATE_FAN_HIGH; + } + + return climate::CLIMATE_FAN_ON; +} + +TccLinkClimate::TccLinkClimate() { + target_temperature = NAN; + this->traits_.set_supports_action(true); + this->traits_.set_supports_current_temperature(true); + this->traits_.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_HEAT_COOL, + }); + this->traits_.set_supported_fan_modes({ + climate::CLIMATE_FAN_OFF, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + this->traits_.set_supports_two_point_target_temperature(false); + this->traits_.set_visual_min_temperature(18); + this->traits_.set_visual_max_temperature(29); + this->traits_.set_visual_temperature_step(0.5); +} + +climate::ClimateTraits TccLinkClimate::traits() { return traits_; } + +void TccLinkClimate::dump_config() { + ESP_LOGCONFIG(TAG, "TCC Link:"); + this->dump_traits_(TAG); +} + +void TccLinkClimate::setup() { + if (this->failed_crcs_sensor_ != nullptr) { + this->failed_crcs_sensor_->publish_state(0); + } +} + +void log_data_frame(const std::string msg, const struct DataFrame *frame, size_t length = 0) { + std::string res; + char buf[5]; + size_t len = length > 0 ? length : frame->data_length; + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += ':'; + } + sprintf(buf, "%02X", frame->data[i]); + res += buf; + } + ESP_LOGD(TAG, "%s: %02X:%02X:\x1B[32m%02X\033[0m:%02X:\033[2;100;37m%s\033[0m:%02X", msg.c_str(), frame->source, + frame->dest, frame->opcode1, frame->data_length, res.c_str(), frame->crc()); +} + +void log_raw_data(const std::string prefix, const uint8_t raw[], size_t size) { + std::string res; + char buf[size]; + for (size_t i = 0; i < size; i++) { + if (i > 0) { + res += ':'; + } + sprintf(buf, "%02X", raw[i]); + res += buf; + } + ESP_LOGV(TAG, "%s%s", prefix.c_str(), res.c_str()); +} + +void TccLinkClimate::sync_from_received_state() { + uint8_t changes = 0; + + auto new_mode = to_climate_mode(&tcc_state); + if (new_mode != mode) { + mode = new_mode; + changes++; + } + + auto new_action = to_climate_action(&tcc_state); + if (new_action != action) { + action = new_action; + changes++; + } + + auto new_fan_mode = to_climate_fan(&tcc_state); + if (new_fan_mode != fan_mode) { + fan_mode = new_fan_mode; + changes++; + } + + if (target_temperature != tcc_state.target_temp) { + target_temperature = tcc_state.target_temp; + changes++; + } + + if (current_temperature != tcc_state.room_temp) { + current_temperature = tcc_state.room_temp; + changes++; + } + + if (changes > 0) { + this->publish_state(); + } + + if (this->vent_switch_) { + this->vent_switch_->publish_state(tcc_state.vent); + } +} + +void TccLinkClimate::process_received_data(const struct DataFrame *frame) { + switch (frame->source) { + case 0x00: + case TOSHIBA_MASTER: + // status update + + last_master_alive_millis_ = millis(); + if (this->connected_binary_sensor_) { + this->connected_binary_sensor_->publish_state(true); + } + + switch (frame->opcode1) { + case OPCODE_PING: + log_data_frame("PING", frame); + break; + // case OPCODE_ACK: + // ESP_LOGD(TAG, " Command acknoledged"); + break; + case OPCODE_PARAMETER: + // master reporting it's state + // e.g. 01:52:11:04:80:86:A1:05:E4 + /* + 00 52 11 04 80 86 24 00 65 heat + |-opc1 | |- mode bit7-bit5, power bit0, bit2 ??? + |- 0010 0100 -> mode bit7-bit5 bit4-bit0 ??? + --- + */ + log_data_frame("MASTER PARAMETERS", frame); + + tcc_state.power = (frame->data[3] & STATUS_DATA_POWER_MASK); + tcc_state.mode = + (frame->data[STATUS_DATA_MODEPOWER_BYTE] & STATUS_DATA_MODE_MASK) >> STATUS_DATA_MODE_SHIFT_BITS; + tcc_state.cooling = (frame->data[STATUS_DATA_MODEPOWER_BYTE] & 0b00001000) >> 3; + tcc_state.heating = (frame->data[STATUS_DATA_MODEPOWER_BYTE] & 0b00000001); + // tcc_state.heating = (frame->data[3] & 0b0100) >> 2; + tcc_state.preheating = (frame->data[3] & 0b0100) >> 2; + + ESP_LOGD(TAG, "Mode: %02X, Cooling: %d, Heating: %d, Preheating: %d", tcc_state.mode, tcc_state.cooling, + tcc_state.heating, tcc_state.preheating); + + sync_from_received_state(); + + break; + case OPCODE_STATUS: + // sync power, mode, fan and target temp from the unit to the climate + // component + + log_data_frame("STATUS", frame); + + // this message means that the command sent to master was confirmed + // (may be it can return an error, but no idea how to read that at the + // moment) + if (last_unconfirmed_command_.has_value()) { + // TODO: check if this is the right command being confirmed + + last_unconfirmed_command_ = {}; // reset last command + } + + tcc_state.power = (frame->data[STATUS_DATA_MODEPOWER_BYTE] & STATUS_DATA_POWER_MASK); + tcc_state.mode = + (frame->data[STATUS_DATA_MODEPOWER_BYTE] & STATUS_DATA_MODE_MASK) >> STATUS_DATA_MODE_SHIFT_BITS; + tcc_state.fan = (frame->data[STATUS_DATA_FANVENT_BYTE] & STATUS_DATA_FAN_MASK) >> STATUS_DATA_FAN_SHIFT_BITS; + tcc_state.vent = + (frame->data[STATUS_DATA_FANVENT_BYTE] & STATUS_DATA_VENT_MASK) >> STATUS_DATA_VENT_SHIFT_BITS; + tcc_state.target_temp = + static_cast(frame->data[STATUS_DATA_TARGET_TEMP_BYTE] & TEMPERATURE_DATA_MASK) / + TEMPERATURE_CONVERSION_RATIO - + TEMPERATURE_CONVERSION_OFFSET; + // don't set heating of cooling from command status update, since the + // actuall will be delayed and will e reported via MASTER PARAMETER + // tcc_state.cooling = (frame->data[7] & 0b1000) >> 3; + // tcc_state.heating = (frame->data[7] & 0b0001); + tcc_state.preheating = (frame->data[4] & 0b10) >> 1; + + ESP_LOGD(TAG, "Mode: %02X, Preheating: %d", tcc_state.mode, tcc_state.preheating); + + sync_from_received_state(); + + break; + case OPCODE_EXTENDED_STATUS: + // sync power, mode, fan and target temp from the unit to the climate + // component + + log_data_frame("EXTENDED STATUS", frame); + + tcc_state.power = (frame->data[STATUS_DATA_MODEPOWER_BYTE] & STATUS_DATA_POWER_MASK); + tcc_state.mode = + (frame->data[STATUS_DATA_MODEPOWER_BYTE] & STATUS_DATA_MODE_MASK) >> STATUS_DATA_MODE_SHIFT_BITS; + tcc_state.fan = (frame->data[STATUS_DATA_FANVENT_BYTE] & STATUS_DATA_FAN_MASK) >> STATUS_DATA_FAN_SHIFT_BITS; + tcc_state.vent = + (frame->data[STATUS_DATA_FANVENT_BYTE] & STATUS_DATA_VENT_MASK) >> STATUS_DATA_VENT_SHIFT_BITS; + tcc_state.target_temp = + static_cast(frame->data[STATUS_DATA_TARGET_TEMP_BYTE] & TEMPERATURE_DATA_MASK) / + TEMPERATURE_CONVERSION_RATIO - + TEMPERATURE_CONVERSION_OFFSET; + + if (frame->data[STATUS_DATA_TARGET_TEMP_BYTE + 1] > 1) { + tcc_state.room_temp = + static_cast(frame->data[STATUS_DATA_TARGET_TEMP_BYTE + 1]) / TEMPERATURE_CONVERSION_RATIO - + TEMPERATURE_CONVERSION_OFFSET; + } + + tcc_state.preheating = (frame->data[4] & 0b10) >> 1; + ESP_LOGD(TAG, "Mode: %02X, Preheating: %d", tcc_state.mode, tcc_state.preheating); + + sync_from_received_state(); + + break; + default: + log_data_frame("MASTER", frame); + break; + } + + break; + case TOSHIBA_REMOTE: + // command + log_data_frame("REMOTE", frame); + if (frame->opcode1 == OPCODE_TEMPERATURE) { + // current temperature is reported by the remote + if (frame->data[3] > 1) { + tcc_state.room_temp = + static_cast(frame->data[3]) / TEMPERATURE_CONVERSION_RATIO - TEMPERATURE_CONVERSION_OFFSET; + sync_from_received_state(); + } + } + break; + default: + log_data_frame("UNKNOWN", frame); + break; + } +} + +bool TccLinkClimate::receive_data(const std::vector data) { + auto frame = DataFrame(); + + for (size_t i = 0; i < data.size(); i++) { + frame.raw[i] = data[i]; + } + + return receive_data_frame(&frame); +} + +bool TccLinkClimate::receive_data_frame(const struct DataFrame *frame) { + if (frame->crc() != frame->calculate_crc()) { + ESP_LOGW(TAG, "CRC check failed"); + log_data_frame("Failed frame", frame); + + if (this->failed_crcs_sensor_ != nullptr) { + this->failed_crcs_sensor_->publish_state(this->failed_crcs_sensor_->state + 1); + } + + return false; + } + + this->set_data_received_callback_.call(frame); + + process_received_data(frame); + + return true; +} + +void TccLinkClimate::loop() { + // TODO: check if last_unconfirmed_command_ was not confirmed after a timeout + // and log warning/error + + if (!this->write_queue_.empty() && (millis() - last_received_frame_millis_) >= FRAME_SEND_MILLIS_FROM_LAST_RECEIVE && + (millis() - last_sent_frame_millis_) >= FRAME_SEND_MILLIS_FROM_LAST_SEND) { + last_sent_frame_millis_ = millis(); + auto frame = this->write_queue_.front(); + last_unconfirmed_command_ = frame; + log_data_frame("Write frame", &frame); + this->write_array(frame.raw, frame.size()); + this->write_queue_.pop(); + if (this->write_queue_.empty()) { + ESP_LOGD(TAG, "All frames written"); + } + } + + uint8_t bytes_read = 0; + + while (available()) { + int byte = read(); + if (byte >= 0) { + bytes_read++; + + if (!can_read_packet) + continue; // wait until can read packet + + if (data_reader.put(byte)) { + // packet complete + + last_received_frame_millis_ = millis(); + + auto frame = data_reader.frame; + + if (!receive_data_frame(&frame)) { + } + + data_reader.reset(); + + // read next packet (if any in the next loop) + // the smallest packet (ALIVE) is 32ms wide, + // which means there are max ~31 packets per second. + // and the loop runs 33-50 times per second. + // so should be enough throughput to process packets. + // this ensure that each packet is interpreted separately + break; + } + } else { + ESP_LOGW(TAG, "Unable to read data"); + } + } + + if (bytes_read > 0) { + loops_with_reads_++; + loops_without_reads_ = 0; + + // ESP_LOGV(TAG, "Bytes of data read: %d", bytes_read); + // if (!data_reader.complete) { + // log_data_frame("Pending", data_reader.frame); + // } + + last_read_millis_ = millis(); + } else { + loops_without_reads_++; + loops_with_reads_ = 0; + + if (last_read_millis_ > 0) { + auto millis_since_last_read = millis() - last_read_millis_; + if (millis_since_last_read >= PACKET_MIN_WAIT_MILLIS) { + // can start reading packet + + if (!data_reader.complete && data_reader.data_index_ > 0) { + // ESP_LOGW(TAG, "Reset pending frame buffer (%d)", + // data_reader.data_index_); log_raw_data("Pending: ", + // data_reader.frame.raw, data_reader.data_index_); + } + can_read_packet = true; + data_reader.reset(); + last_read_millis_ = 0; + } + } + } + + if (last_master_alive_millis_ > 0 && (millis() - last_master_alive_millis_) > LAST_ALIVE_TIMEOUT_MILLIS) { + // not connected + if (this->connected_binary_sensor_) { + this->connected_binary_sensor_->publish_state(false); + } + } +} + +size_t TccLinkClimate::send_new_state(const struct TccState *new_state) { + auto commands = create_commands(new_state); + if (commands.empty()) { + ESP_LOGD(TAG, "New state has not changed. Nothing to send"); + } else { + ESP_LOGD(TAG, "Send %d commands", commands.size()); + for (auto cmd : commands) { + send_command(cmd); + } + } + + return commands.size(); +} + +std::vector TccLinkClimate::create_commands(const struct TccState *new_state) { + auto commands = std::vector(); + + if (new_state->power != tcc_state.power) { + if (new_state->power) { + // turn on + ESP_LOGD(TAG, "Turning on"); + auto command = DataFrame{}; + write_set_parameter_power(&command, new_state); + commands.push_back(command); + } else { + // turn off + ESP_LOGD(TAG, "Turning off"); + auto command = DataFrame{}; + write_set_parameter_power(&command, new_state); + commands.push_back(command); + // don't process other changes when turning off + return commands; + } + } + + if (new_state->mode != tcc_state.mode) { + ESP_LOGD(TAG, "Changing mode"); + auto command = DataFrame{}; + write_set_parameter_mode(&command, new_state); + commands.push_back(command); + } + + if (new_state->fan != tcc_state.fan) { + ESP_LOGD(TAG, "Changing fan"); + auto command = DataFrame{}; + write_set_parameter_flags(&command, new_state, COMMAND_SET_FAN); + commands.push_back(command); + } + + if (new_state->target_temp != tcc_state.target_temp) { + ESP_LOGD(TAG, "Changing target temperature"); + auto command = DataFrame{}; + write_set_parameter_flags(&command, new_state, COMMAND_SET_TEMP); + commands.push_back(command); + } + + if (new_state->vent != tcc_state.vent) { + ESP_LOGD(TAG, "Changing vent"); + auto command = DataFrame{}; + write_set_parameter_vent(&command, new_state); + commands.push_back(command); + } + + return commands; +} + +void TccLinkClimate::control(const climate::ClimateCall &call) { + TccState new_state = TccState{tcc_state}; + + if (call.get_mode().has_value()) { + ESP_LOGD(TAG, "Control mode"); + auto mode = call.get_mode().value(); + new_state.power = to_tcc_power(mode); + new_state.mode = to_tcc_mode(mode); + } + + if (call.get_fan_mode().has_value()) { + ESP_LOGD(TAG, "Control fan"); + new_state.fan = to_tcc_fan(call.get_fan_mode().value()); + } + + if (call.get_target_temperature().has_value()) { + ESP_LOGD(TAG, "Control target temperature"); + new_state.target_temp = call.get_target_temperature().value(); + } + + send_new_state(&new_state); +} + +void TccLinkClimate::send_command(const struct DataFrame command) { + log_data_frame("Enqueue command", &command); + this->write_queue_.push(command); +} + +bool TccLinkClimate::control_vent(bool state) { + if (!tcc_state.power) { + ESP_LOGW(TAG, "Can't control vent when powered off"); + return false; + } + ESP_LOGD(TAG, "Control vent: %d", state); + TccState new_state = TccState{tcc_state}; + new_state.vent = state; + return send_new_state(&new_state) > 0; +} + +void TccLinkVentSwitch::write_state(bool state) { + if (this->climate_->control_vent(state)) { + // don't publish state. wait for the unit to report it's state + } +} + +} // namespace tcc_link +} // namespace esphome diff --git a/components/tcc_link/tcc_link.h b/components/tcc_link/tcc_link.h new file mode 100644 index 0000000..694a73c --- /dev/null +++ b/components/tcc_link/tcc_link.h @@ -0,0 +1,331 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/uart/uart.h" +#include +#include + +namespace esphome { +namespace tcc_link { + +const uint32_t ALIVE_MESSAGE_PERIOD_MILLIS = 5000; +const uint32_t LAST_ALIVE_TIMEOUT_MILLIS = ALIVE_MESSAGE_PERIOD_MILLIS * 3 + 1000; + +const uint32_t PACKET_MIN_WAIT_MILLIS = 200; +const uint32_t FRAME_SEND_MILLIS_FROM_LAST_RECEIVE = 500; +const uint32_t FRAME_SEND_MILLIS_FROM_LAST_SEND = 500; + +const uint8_t TOSHIBA_MASTER = 0x01; +const uint8_t TOSHIBA_REMOTE = 0x40; +const uint8_t TOSHIBA_BROADCAST = 0xFE; +const uint8_t TOSHIBA_REPORT = 0x52; + +const uint8_t OPCODE_PING = 0x10; +const uint8_t OPCODE_PARAMETER = 0x11; +const uint8_t OPCODE_ERROR_HISTORY = 0x15; +const uint8_t OPCODE_SENSOR_QUERY = 0x17; +const uint8_t OPCODE_ACK = 0x18; +const uint8_t OPCODE_SENSOR_VALUE = 0x1A; +const uint8_t OPCODE_STATUS = 0x1C; +const uint8_t OPCODE_TEMPERATURE = 0x55; +const uint8_t OPCODE_EXTENDED_STATUS = 0x58; + +const uint8_t STATUS_DATA_MODEPOWER_BYTE = 2; +const uint8_t STATUS_DATA_POWER_MASK = 0b00000001; +const uint8_t STATUS_DATA_MODE_MASK = 0b11100000; +const uint8_t STATUS_DATA_MODE_SHIFT_BITS = 5; +// const uint8_t STATUS_DATA_COOLING_MASK = 0b00001000; // e.g. 48 in +// 01:52:11:04:80:86:48:01:09 const uint8_t STATUS_DATA_COOLING_SHIFT_BITS = 3; +// const uint8_t STATUS_DATA_HEATING_MASK = 0b00000001; // e.g. 81 in +// 01:52:11:04:80:86:81:01:C0. Also in DRY mode +const uint8_t STATUS_DATA_FANVENT_BYTE = 3; +const uint8_t STATUS_DATA_FAN_MASK = 0b11100000; +const uint8_t STATUS_DATA_FAN_SHIFT_BITS = 5; +const uint8_t STATUS_DATA_VENT_MASK = 0b00000100; +const uint8_t STATUS_DATA_VENT_SHIFT_BITS = 2; +const uint8_t STATUS_DATA_TARGET_TEMP_BYTE = 6; + +const uint8_t COMMAND_MODE_READ = 0x08; +const uint8_t COMMAND_MODE_WRITE = 0x80; + +const uint8_t COMMAND_SET_TEMP = 0b00001000; +const uint8_t COMMAND_SET_FAN = 0b00010000; + +const uint8_t COMMAND_MODE_FLAGS_TEMP = 0b00001000; +const uint8_t COMMAND_MODE_FLAGS_FAN = 0b00010000; + +const uint8_t OPCODE2_READ_STATUS = 0x81; +const uint8_t OPCODE2_READ_ALIVE = 0x8A; +const uint8_t OPCODE2_PARAM_ACK = 0xA1; +const uint8_t OPCODE2_READ_MODE = 0x86; +const uint8_t OPCODE2_PING_PONG = 0x0C; +const uint8_t OPCODE2_SET_POWER = 0x41; +const uint8_t OPCODE2_SET_MODE = 0x42; +const uint8_t OPCODE2_SET_TEMP_WITH_FAN = 0x4C; +const uint8_t OPCODE2_SET_VENT = 0x52; +const uint8_t OPCODE2_SAVE = 0x54; +const uint8_t OPCODE2_SENSOR_QUERY = 0x80; +const uint8_t OPCODE2_SENSOR_ROOM_TEMP = 0x81; + +const uint8_t TEMPERATURE_DATA_MASK = 0b11111110; +const float TEMPERATURE_CONVERSION_RATIO = 2.0; +const float TEMPERATURE_CONVERSION_OFFSET = 35.0; + +const uint8_t EMPTY_DATA = 0x00; + +const uint8_t POWER_ON = 0x01; +const uint8_t POWER_OFF = 0x00; + +const uint8_t MODE_MASK = 0x07; +const uint8_t MODE_HEAT = 0x01; +const uint8_t MODE_COOL = 0x02; +const uint8_t MODE_FAN_ONLY = 0x03; +const uint8_t MODE_DRY = 0x04; +const uint8_t MODE_AUTO = 0x05; + +const uint8_t FAN_PEED_AUTO = 0x02; +const uint8_t FAN_PEED_LOW = 0x05; +const uint8_t FAN_PEED_MED = 0x04; +const uint8_t FAN_PEED_HIGH = 0x03; + +const uint8_t SET_PARAMETER_PAYLOAD_HEADER_SIZE = 2; + +const uint8_t DATA_FRAME_MAX_SIZE = 128; +const uint8_t DATA_MAX_SIZE = DATA_FRAME_MAX_SIZE - 4; // exclude crc +const uint8_t DATA_MIN_SIZE = 2; + +const uint8_t DATA_OFFSET_FROM_START = 4; + +const uint8_t DATA_FRAME_SOURCE = 0; +const uint8_t DATA_FRAME_DEST = 1; +const uint8_t DATA_FRAME_OPCODE1 = 2; +const uint8_t DATA_FRAME_DATA_LENGTH = 3; +const uint8_t DATA_FRAME_READWRITE_FLAGS = 5; +const uint8_t DATA_FRAME_OPCODE2 = 2; + +struct DataFrame { + union { + uint8_t raw[DATA_FRAME_MAX_SIZE]; + struct { + uint8_t source; + uint8_t dest; + uint8_t opcode1; + uint8_t data_length; + uint8_t data[DATA_MAX_SIZE]; + }; + }; + + /** + * Get size of the raw data (needs data_length set). + * Returns 0 if not present. + */ + size_t size() const { + if (!validate_bounds()) + return 0; + + return DATA_OFFSET_FROM_START + data_length + 1; // 1 for CRC byte end the end + } + + /** + * Check if data frame looks good size wise + */ + bool validate_bounds() const { return data_length >= DATA_MIN_SIZE && data_length <= DATA_MAX_SIZE; } + + /** + * Check if CRC in data matches calcualted CRC. + */ + bool validate_crc() const { + if (!validate_bounds()) + return false; + + return crc() == calculate_crc(); + } + + /** + * Get CRC byte at the end of the data. + * Returns 0 if not yet available + */ + uint8_t crc() const { + if (!validate_bounds()) + return 0; + + return raw[size() - 1]; + } + + /** + * Calculates CRC on the current data by creating an XOR sum + */ + uint8_t calculate_crc() const { + if (!validate_bounds()) + return 0; + + uint8_t result = 0; + size_t len = size() - 1; // exclude CRC byte and the end + for (size_t i = 0; i < len; i++) { + result ^= raw[i]; + } + return result; + } + + void reset() { + for (size_t i = 0; i < DATA_FRAME_MAX_SIZE; i++) { + raw[i] = 0; + } + } + + std::vector get_data() const { return std::vector(raw, raw + size()); } +}; + +struct DataFrameReader { + DataFrame frame; + + bool crc_valid; + bool complete; + uint8_t data_index_; + + void reset() { + frame.reset(); + crc_valid = false; + data_index_ = 0; + complete = false; + } + + bool put(uint8_t byte) { + if (data_index_ == 0 && byte == 0xFF) + return false; // filter out noise + + frame.raw[data_index_] = byte; + // ESP_LOGV("READER", "[%d/%d] = %02X", data_index_, frame.size(), byte); + if (data_index_ > DATA_OFFSET_FROM_START && (data_index_ + 1) == frame.size()) { + // last byte + crc_valid = frame.validate_crc(); + data_index_ = 0; // prepare for next frame + complete = true; + return true; + } else { + data_index_++; + if (data_index_ == DATA_FRAME_MAX_SIZE) { + data_index_ = 0; + ESP_LOGW("READER", "Went over buffer"); + } + return false; + } + } + + private: +}; + +struct TccState { + uint8_t mode; + uint8_t fan; + uint8_t vent; + float room_temp = NAN; + float target_temp = NAN; + uint8_t power; + uint8_t cooling; + uint8_t heating; + uint8_t preheating; + + TccState(){}; + + TccState(const struct TccState *src) { + mode = src->mode; + fan = src->fan; + vent = src->vent; + room_temp = src->room_temp; + target_temp = src->target_temp; + power = src->power; + cooling = src->cooling; + heating = src->heating; + preheating = src->preheating; + }; +}; + +class TccLinkClimate : public Component, public uart::UARTDevice, public climate::Climate { + public: + TccLinkClimate(); + + void dump_config() override; + void setup() override; + void loop() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + void set_connected_binary_sensor(binary_sensor::BinarySensor *connected_binary_sensor) { + connected_binary_sensor_ = connected_binary_sensor; + } + + void set_vent_switch(switch_::Switch *vent_switch) { vent_switch_ = vent_switch; } + + void set_failed_crcs_sensor(sensor::Sensor *failed_crcs_sensor) { this->failed_crcs_sensor_ = failed_crcs_sensor; } + + void send_command(struct DataFrame command); + + bool control_vent(bool state); + + bool receive_data(const std::vector data); + bool receive_data_frame(const struct DataFrame *frame); + + void add_on_data_received_callback(std::function &&callback) { + this->set_data_received_callback_.add(std::move(callback)); + } + + protected: + climate::ClimateTraits traits_; + + DataFrameReader data_reader; + TccState tcc_state; + + void process_received_data(const struct DataFrame *frame); + size_t send_new_state(const struct TccState *new_state); + void sync_from_received_state(); + + std::vector create_commands(const struct TccState *new_state); + + // sensors + binary_sensor::BinarySensor *connected_binary_sensor_{nullptr}; + switch_::Switch *vent_switch_{nullptr}; + sensor::Sensor *failed_crcs_sensor_{nullptr}; + + // callbacks + CallbackManager set_data_received_callback_{}; + + private: + uint32_t loops_without_reads_ = 0; + uint32_t loops_with_reads_ = 0; + uint32_t last_read_millis_ = 0; + bool can_read_packet = false; + + uint32_t last_received_frame_millis_ = 0; + uint32_t last_sent_frame_millis_ = 0; + std::queue write_queue_; + optional last_unconfirmed_command_; + + uint32_t last_master_alive_millis_ = 0; +}; + +class TccLinkVentSwitch : public switch_::Switch, public Component { + public: + TccLinkVentSwitch(TccLinkClimate *climate) { climate_ = climate; } + + // void setup() override; + // void dump_config() override; + + // void loop() override; + + // float get_setup_priority() const override; + + protected: + // bool assumed_state() override; + + void write_state(bool state) override; + + TccLinkClimate *climate_; +}; + +} // namespace tcc_link +} // namespace esphome diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..c8a954d --- /dev/null +++ b/example.yaml @@ -0,0 +1,38 @@ +substitutions: + device_name: "Toshiba AC Example" + +esphome: + name: toshiba-ac-example + platform: ESP8266 + board: esp12e + +logger: + level: DEBUG + +external_components: + - source: ./components + +status_led: + pin: + number: GPIO2 + inverted: true + +uart: + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: 2400 + parity: EVEN + +climate: + - platform: tcc_link + name: "${device_name}" + id: toshiba_ac + connected: + name: "${device_name} Connected" + failed_crcs: + name: "${device_name} Failed CRCs" + vent: + name: "${device_name} Vent" + on_data_received: + - lambda: |- + ESP_LOGD("TCC", "Data received: %d bytes", x.size()); \ No newline at end of file diff --git a/format.sh b/format.sh new file mode 100755 index 0000000..871637a --- /dev/null +++ b/format.sh @@ -0,0 +1,3 @@ +#!/bin/bash +clang-format -i components/tcc_link/tcc_link.h +clang-format -i components/tcc_link/tcc_link.cpp \ No newline at end of file