From 9f8d3aa2de25ebcfba00b4c708a06730ae076acf Mon Sep 17 00:00:00 2001 From: dentra Date: Wed, 31 Jan 2024 21:01:14 +0300 Subject: [PATCH] #21 split presets logic to separate file --- components/tion/tion_climate.cpp | 175 -------- components/tion/tion_climate_component.cpp | 192 +++------ components/tion/tion_climate_component.h | 59 ++- components/tion/tion_component.cpp | 20 - components/tion/tion_component.h | 11 - components/tion/tion_defines.h | 15 + components/tion/tion_helpers.h | 35 ++ components/tion/tion_presets.cpp | 271 +++++++++++++ .../tion/{tion_climate.h => tion_presets.h} | 129 +++--- scripts/chk-compile | 2 +- tests/_cloak/ESPAsyncTCP.h | 3 +- tests/_cloak/ESPAsyncUDP.h | 13 + .../_cloak/esphome/components/switch/switch.h | 1 + tests/_cloak/runner.sh | 2 +- tests/emu_t3s.py | 379 ++++++++++++++++++ tests/test_api_o2.cpp | 78 ++++ tests/test_nvs.cpp | 155 +++++++ 17 files changed, 1110 insertions(+), 430 deletions(-) delete mode 100644 components/tion/tion_climate.cpp create mode 100644 components/tion/tion_defines.h create mode 100644 components/tion/tion_helpers.h create mode 100644 components/tion/tion_presets.cpp rename components/tion/{tion_climate.h => tion_presets.h} (60%) create mode 100644 tests/emu_t3s.py create mode 100644 tests/test_nvs.cpp diff --git a/components/tion/tion_climate.cpp b/components/tion/tion_climate.cpp deleted file mode 100644 index 77b2bd9..0000000 --- a/components/tion/tion_climate.cpp +++ /dev/null @@ -1,175 +0,0 @@ -#include "esphome/core/defines.h" -#ifdef USE_CLIMATE -#include "esphome/core/log.h" -#include "tion_climate.h" - -namespace esphome { -namespace tion { - -static const char *const TAG = "tion_climate"; - -climate::ClimateTraits TionClimate::traits() { - auto traits = climate::ClimateTraits(); - traits.set_supports_current_temperature(true); - traits.set_visual_min_temperature(TION_MIN_TEMPERATURE); - traits.set_visual_max_temperature(TION_MAX_TEMPERATURE); - traits.set_visual_temperature_step(1.0f); - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, -#ifdef TION_ENABLE_CLIMATE_MODE_HEAT_COOL - climate::CLIMATE_MODE_HEAT_COOL, -#endif - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_FAN_ONLY, - }); - for (uint8_t i = 1, max = i + TION_MAX_FAN_SPEED; i < max; i++) { - traits.add_supported_custom_fan_mode(this->fan_speed_to_mode_(i)); - } -#ifdef TION_ENABLE_PRESETS - traits.add_supported_preset(climate::CLIMATE_PRESET_NONE); - this->for_each_preset_([&traits](auto index) { traits.add_supported_preset(index); }); -#endif - traits.set_supports_action(true); - return traits; -} - -void TionClimate::control(const climate::ClimateCall &call) { -#ifdef TION_ENABLE_PRESETS - if (call.get_preset().has_value()) { - if (this->enable_preset_(*call.get_preset())) { - return; - } - } -#endif - - climate::ClimateMode mode = this->mode; - if (call.get_mode().has_value()) { - mode = *call.get_mode(); - ESP_LOGD(TAG, "Set mode %s", LOG_STR_ARG(climate::climate_mode_to_string(mode))); - this->preset = climate::CLIMATE_PRESET_NONE; - } - - uint8_t fan_speed = this->fan_mode_to_speed_(this->custom_fan_mode); - if (call.get_custom_fan_mode().has_value()) { - fan_speed = this->fan_mode_to_speed_(call.get_custom_fan_mode()); - ESP_LOGD(TAG, "Set fan speed %u", fan_speed); - this->preset = climate::CLIMATE_PRESET_NONE; - } - - float target_temperature = this->target_temperature; - if (call.get_target_temperature().has_value()) { - target_temperature = *call.get_target_temperature(); - ESP_LOGD(TAG, "Set target temperature %d °C", int(target_temperature)); - this->preset = climate::CLIMATE_PRESET_NONE; - } - - this->control_climate_state(mode, fan_speed, target_temperature, TION_CLIMATE_GATE_POSITION_NONE); -} - -void TionClimate::set_fan_speed_(uint8_t fan_speed) { - if (fan_speed > 0 && fan_speed <= TION_MAX_FAN_SPEED) { - this->custom_fan_mode = this->fan_speed_to_mode_(fan_speed); - } else { - auto ok_fan_speed = this->mode == climate::CLIMATE_MODE_OFF && fan_speed == 0; - if (!ok_fan_speed) { - ESP_LOGW(TAG, "Unsupported fan speed %u (max: %u)", fan_speed, TION_MAX_FAN_SPEED); - } - } -} - -void TionClimate::dump_presets(const char *tag) const { -#ifdef TION_ENABLE_PRESETS - auto has_presets = false; - this->for_each_preset_([&has_presets](auto index) { has_presets = true; }); - if (has_presets) { - ESP_LOGCONFIG(tag, " Presets (fan_speed, target_temperature, mode, gate_position):"); - this->for_each_preset_([tag, this](auto index) { this->dump_preset_(tag, index); }); - } -#endif -} - -#ifdef TION_ENABLE_PRESETS -void TionClimate::dump_preset_(const char *tag, climate::ClimatePreset index) const { - auto gate_position_to_string = [](TionClimateGatePosition gp) -> const char * { - switch (gp) { - case TION_CLIMATE_GATE_POSITION_NONE: - return "none"; - case TION_CLIMATE_GATE_POSITION_OUTDOOR: - return "outdoor"; - case TION_CLIMATE_GATE_POSITION_INDOOR: - return "indoor"; - case TION_CLIMATE_GATE_POSITION_MIXED: - return "mixed"; - default: - return "unknown"; - } - }; - const auto &preset = this->presets_[index]; - const auto *preset_str = LOG_STR_ARG(climate::climate_preset_to_string(index)); - const auto *mode_str = LOG_STR_ARG(climate::climate_mode_to_string(preset.mode)); - const auto *gate_pos_str = gate_position_to_string(preset.gate_position); -#ifdef USE_ESP8266 - ESP_LOGCONFIG(tag, " %-8s: %u, %2d, %-8s, %s", preset_str, preset.fan_speed, preset.target_temperature, mode_str, - gate_pos_str); -#else - ESP_LOGCONFIG(tag, " %-8s: %u, %2d, %-8s, %s", str_lower_case(preset_str).c_str(), preset.fan_speed, - preset.target_temperature, str_lower_case(mode_str).c_str(), gate_pos_str); -#endif -} - -bool TionClimate::enable_preset_(climate::ClimatePreset new_preset) { - const auto old_preset = *this->preset; - if (new_preset == old_preset) { - ESP_LOGD(TAG, "Preset was not changed"); - return false; - } - - if (old_preset == climate::CLIMATE_PRESET_BOOST) { - ESP_LOGD(TAG, "Cancel preset boost"); - this->cancel_boost(); - } - - ESP_LOGD(TAG, "Enable preset %s", LOG_STR_ARG(climate::climate_preset_to_string(new_preset))); - if (new_preset == climate::CLIMATE_PRESET_BOOST) { - if (!this->enable_boost()) { - return false; - } - this->saved_preset_ = old_preset; - // инициализируем дефолный пресет NONE чтобы можно было в него восстановиться в любом случае - if (!this->presets_[climate::CLIMATE_PRESET_NONE].is_initialized() && old_preset != climate::CLIMATE_PRESET_NONE) { - this->update_default_preset_(); - } - } - - // если был пресет NONE, то сохраним его текущее состояние - if (old_preset == climate::CLIMATE_PRESET_NONE) { - this->update_default_preset_(); - } - - // дополнительно проверим, что пресет был предварительно сохранен (см. блок выше) - // в противном случае можем получить зимой, например, отстутсвие подогрева - // т.е. неинициализированный пресет не активируем - if (!this->presets_[new_preset].is_initialized()) { - ESP_LOGW(TAG, "No data for preset %s", LOG_STR_ARG(climate::climate_preset_to_string(new_preset))); - return false; - } - - this->control_climate_state(this->presets_[new_preset].mode, this->presets_[new_preset].fan_speed, - this->presets_[new_preset].target_temperature, this->presets_[new_preset].gate_position); - - this->preset = new_preset; - - return true; -} - -// TODO remove this method and use this->enable_preset_(this->saved_preset_); -void TionClimate::cancel_preset_(climate::ClimatePreset old_preset) { - if (old_preset == climate::CLIMATE_PRESET_BOOST) { - this->enable_preset_(this->saved_preset_); - } -} -#endif - -} // namespace tion -} // namespace esphome -#endif diff --git a/components/tion/tion_climate_component.cpp b/components/tion/tion_climate_component.cpp index 878d744..b5e5cbe 100644 --- a/components/tion/tion_climate_component.cpp +++ b/components/tion/tion_climate_component.cpp @@ -18,133 +18,11 @@ namespace tion { static const char *const TAG = "tion_climate_component"; -// boost time update interval -#define BOOST_TIME_UPDATE_INTERVAL_SEC 20 - -// application scheduler name -static const char *const ASH_BOOST = "tion-boost"; - void TionClimateComponentBase::call_setup() { TionComponent::call_setup(); - -#ifdef TION_ENABLE_PRESETS_WITH_API - this->presets_rtc_ = global_preferences->make_preference(fnv1_hash("presets")); - if (this->presets_rtc_.load(&this->presets_)) { - this->presets_[0].fan_speed = 0; // reset initialization - ESP_LOGD(TAG, "Presets loaded"); - } - this->register_service(&TionClimateComponentBase::update_preset_service_, "update_preset", - {"preset", "mode", "fan_speed", "target_temperature", "gate_position"}); -#endif // TION_ENABLE_PRESETS_WITH_API -} - -#ifdef TION_ENABLE_PRESETS -#ifdef USE_API -// esphome services allows only pass copy of strings -void TionClimateComponentBase::update_preset_service_(std::string preset_str, std::string mode_str, int32_t fan_speed, - int32_t target_temperature, std::string gate_position_str) { - climate::ClimatePreset preset = climate::ClimatePreset::CLIMATE_PRESET_NONE; - if (preset_str.length() > 0) { - auto preset_ch = std::tolower(preset_str[0]); - if (preset_ch == 'h') { // Home - preset = climate::ClimatePreset::CLIMATE_PRESET_HOME; - } else if (preset_ch == 'a') { // Away/Activity - if (preset_str.length() > 1) { - preset_ch = std::tolower(preset_str[1]); - if (preset_ch == 'w') { // aWay - preset = climate::ClimatePreset::CLIMATE_PRESET_AWAY; - } else if (preset_ch == 'c') { // aCtivity - preset = climate::ClimatePreset::CLIMATE_PRESET_ACTIVITY; - } - } - } else if (preset_ch == 'b') { // Boost - preset = climate::ClimatePreset::CLIMATE_PRESET_BOOST; - } else if (preset_ch == 'c') { // Comform - preset = climate::ClimatePreset::CLIMATE_PRESET_COMFORT; - } else if (preset_ch == 'e') { // Eco - preset = climate::ClimatePreset::CLIMATE_PRESET_ECO; - } else if (preset_ch == 's') { // Sleep - preset = climate::ClimatePreset::CLIMATE_PRESET_SLEEP; - } - } - - auto mode = climate::ClimateMode::CLIMATE_MODE_AUTO; - if (mode_str.length() > 0) { - auto mode_ch = std::tolower(mode_str[0]); - if (mode_ch == 'h') { // Heat - mode = climate::ClimateMode::CLIMATE_MODE_HEAT; - } else if (mode_ch == 'f') { // Fan_only - mode = climate::ClimateMode::CLIMATE_MODE_FAN_ONLY; - } else if (mode_ch == 'o') { // Off - mode = climate::ClimateMode::CLIMATE_MODE_OFF; - } - } - - TionClimateGatePosition gate_position = TION_CLIMATE_GATE_POSITION_NONE; - if (gate_position_str.length() > 0) { - auto gate_position_ch = std::tolower(gate_position_str[0]); - if (gate_position_ch == 'o') { // Outdoor - gate_position = TION_CLIMATE_GATE_POSITION_OUTDOOR; - } else if (gate_position_ch == 'i') { // Indoor - gate_position = TION_CLIMATE_GATE_POSITION_INDOOR; - } else if (gate_position_ch == 'm') { // Mixed - gate_position = TION_CLIMATE_GATE_POSITION_MIXED; - } - } - - if (this->update_preset(preset, mode, fan_speed, target_temperature, gate_position)) { - if (this->presets_rtc_.save(&this->presets_)) { - ESP_LOGCONFIG(TAG, "Preset was updated:"); - this->dump_preset_(TAG, preset); - } - } else { - ESP_LOGW(TAG, "Preset %s was't updated", preset_str.c_str()); - } -} -#endif // USE_API - -bool TionClimateComponentBase::enable_boost() { - auto boost_time = this->get_boost_time_(); - if (boost_time == 0) { - ESP_LOGW(TAG, "Boost time is not configured"); - return false; - } - - // if boost_time_left not configured, just schedule stop boost after boost_time - if (this->boost_time_left_ == nullptr) { - ESP_LOGD(TAG, "Schedule boost timeout for %" PRIu32 " s", boost_time); - this->set_timeout(ASH_BOOST, boost_time * 1000, [this]() { this->cancel_preset_(climate::CLIMATE_PRESET_BOOST); }); - return true; - } - - // if boost_time_left is configured, schedule update it - ESP_LOGD(TAG, "Schedule boost interval up to %" PRIu32 " s", boost_time); - this->boost_time_left_->publish_state(static_cast(boost_time)); - this->set_interval(ASH_BOOST, BOOST_TIME_UPDATE_INTERVAL_SEC * 1000, [this]() { - const int32_t time_left = static_cast(this->boost_time_left_->state) - BOOST_TIME_UPDATE_INTERVAL_SEC; - ESP_LOGV(TAG, "Boost time left %" PRId32 " s", time_left); - if (time_left > 0) { - this->boost_time_left_->publish_state(static_cast(time_left)); - } else { - this->cancel_preset_(climate::CLIMATE_PRESET_BOOST); - } - }); - - return true; + this->setup_presets(); } -void TionClimateComponentBase::cancel_boost() { - if (this->boost_time_left_) { - ESP_LOGV(TAG, "Cancel boost interval"); - this->cancel_interval(ASH_BOOST); - this->boost_time_left_->publish_state(NAN); - } else { - ESP_LOGV(TAG, "Cancel boost timeout"); - this->cancel_timeout(ASH_BOOST); - } -} -#endif // TION_ENABLE_PRESETS - void TionClimateComponentBase::dump_settings(const char *TAG, const char *component) const { LOG_CLIMATE(component, "", this); LOG_UPDATE_INTERVAL(this); @@ -184,10 +62,72 @@ void TionClimateComponentBase::dump_settings(const char *TAG, const char *compon ESP_LOGCONFIG(TAG, " State timeout: %.1fs", this->state_timeout_ / 1000.0f); #endif ESP_LOGCONFIG(TAG, " Batch timeout: %.1fs", this->batch_timeout_ / 1000.0f); +} + +climate::ClimateTraits TionClimateComponentBase::traits() { + auto traits = climate::ClimateTraits(); + traits.set_supports_current_temperature(true); + traits.set_visual_min_temperature(TION_MIN_TEMPERATURE); + traits.set_visual_max_temperature(TION_MAX_TEMPERATURE); + traits.set_visual_temperature_step(1.0f); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, +#ifdef TION_ENABLE_CLIMATE_MODE_HEAT_COOL + climate::CLIMATE_MODE_HEAT_COOL, +#endif + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + for (uint8_t i = 1, max = i + TION_MAX_FAN_SPEED; i < max; i++) { + traits.add_supported_custom_fan_mode(fan_speed_to_mode(i)); + } + this->add_presets(traits); + traits.set_supports_action(true); + return traits; +} + +void TionClimateComponentBase::control(const climate::ClimateCall &call) { #ifdef TION_ENABLE_PRESETS - LOG_NUMBER(" ", "Boost Time", this->boost_time_); - LOG_SENSOR(" ", "Boost Time Left", this->boost_time_left_); -#endif // TION_ENABLE_PRESETS + if (call.get_preset().has_value()) { + if (this->enable_preset_(*call.get_preset())) { + return; + } + } +#endif + + climate::ClimateMode mode = this->mode; + if (call.get_mode().has_value()) { + mode = *call.get_mode(); + ESP_LOGD(TAG, "Set mode %s", LOG_STR_ARG(climate::climate_mode_to_string(mode))); + this->preset = climate::CLIMATE_PRESET_NONE; + } + + uint8_t fan_speed = fan_mode_to_speed(this->custom_fan_mode); + if (call.get_custom_fan_mode().has_value()) { + fan_speed = fan_mode_to_speed(*call.get_custom_fan_mode()); + ESP_LOGD(TAG, "Set fan speed %u", fan_speed); + this->preset = climate::CLIMATE_PRESET_NONE; + } + + float target_temperature = this->target_temperature; + if (call.get_target_temperature().has_value()) { + target_temperature = *call.get_target_temperature(); + ESP_LOGD(TAG, "Set target temperature %d °C", int(target_temperature)); + this->preset = climate::CLIMATE_PRESET_NONE; + } + + this->control_climate_state(mode, fan_speed, target_temperature, TION_CLIMATE_GATE_POSITION_NONE); +} + +void TionClimateComponentBase::set_fan_speed_(uint8_t fan_speed) { + if (fan_speed > 0 && fan_speed <= TION_MAX_FAN_SPEED) { + this->custom_fan_mode = fan_speed_to_mode(fan_speed); + } else { + auto ok_fan_speed = this->mode == climate::CLIMATE_MODE_OFF && fan_speed == 0; + if (!ok_fan_speed) { + ESP_LOGW(TAG, "Unsupported fan speed %u (max: %u)", fan_speed, TION_MAX_FAN_SPEED); + } + } } } // namespace tion diff --git a/components/tion/tion_climate_component.h b/components/tion/tion_climate_component.h index d82be8c..7c5d180 100644 --- a/components/tion/tion_climate_component.h +++ b/components/tion/tion_climate_component.h @@ -4,14 +4,7 @@ #ifdef USE_CLIMATE #include "esphome/core/helpers.h" - -#if defined(TION_ENABLE_PRESETS) && defined(USE_API) -#define TION_ENABLE_PRESETS_WITH_API -#endif - -#ifdef TION_ENABLE_PRESETS_WITH_API -#include "esphome/components/api/custom_api_device.h" -#endif // TION_ENABLE_PRESETS_WITH_API +#include "esphome/components/climate/climate.h" #include "../tion-api/tion-api.h" @@ -22,45 +15,49 @@ namespace esphome { namespace tion { -#ifdef TION_ENABLE_PRESETS_WITH_API -using TionApiDevice = api::CustomAPIDevice; -#else -class TionApiDevice {}; -#endif // TION_ENABLE_PRESETS_WITH_API - -class TionClimateComponentBase : public TionClimate, public TionComponent, public TionApiDevice { +class TionClimateComponentBase : public climate::Climate, public TionPresets, public TionComponent { public: TionClimateComponentBase() = delete; TionClimateComponentBase(const TionClimateComponentBase &) = delete; // non construction-copyable TionClimateComponentBase &operator=(const TionClimateComponentBase &) = delete; // non copyable - TionClimateComponentBase(TionVPortType vport_type) : vport_type_(vport_type) {} + TionClimateComponentBase(TionVPortType vport_type) : vport_type_(vport_type) { this->target_temperature = NAN; } void call_setup() override; void dump_settings(const char *tag, const char *component) const; + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + virtual void control_climate_state(climate::ClimateMode mode, uint8_t fan_speed, float target_temperature, + TionClimateGatePosition gate_position) = 0; + + uint8_t get_fan_speed() const { return fan_mode_to_speed(this->custom_fan_mode); } + protected: const TionVPortType vport_type_; + void set_fan_speed_(uint8_t fan_speed); + #ifdef TION_ENABLE_PRESETS - bool enable_boost() override; - void cancel_boost() override; - /// returns boost time in seconds. - uint32_t get_boost_time_() const { - if (this->boost_time_ == nullptr) { - return DEFAULT_BOOST_TIME_SEC; + virtual bool enable_boost() { return this->presets_enable_boost_(this, this); } + virtual void cancel_boost() { this->presets_cancel_boost_(this, this); } + bool enable_preset_(climate::ClimatePreset new_preset) { + auto *preset_data = this->presets_enable_preset_(new_preset, this, this); + if (!preset_data) { + return false; } - if (this->boost_time_->traits.get_unit_of_measurement()[0] == 's') { - return this->boost_time_->state; + this->control_climate_state(preset_data->mode, preset_data->fan_speed, preset_data->target_temperature, + preset_data->gate_position); + this->preset = new_preset; + return true; + } + void cancel_preset_(climate::ClimatePreset old_preset) { + if (this->presets_cancel_preset_(old_preset, this, this)) { + this->preset = old_preset; } - return this->boost_time_->state * 60; } - - void update_preset_service_(std::string preset, std::string mode, int32_t fan_speed, int32_t target_temperature, - std::string gate_position); -#endif -#ifdef TION_ENABLE_PRESETS_WITH_API - ESPPreferenceObject presets_rtc_; #endif + }; // namespace tion /** diff --git a/components/tion/tion_component.cpp b/components/tion/tion_component.cpp index cfb365b..8da48a6 100644 --- a/components/tion/tion_component.cpp +++ b/components/tion/tion_component.cpp @@ -10,26 +10,6 @@ namespace tion { static const char *const TAG = "tion_component"; -void TionComponent::call_setup() { - PollingComponent::call_setup(); -#ifdef TION_ENABLE_PRESETS - if (this->boost_time_) { - this->boost_rtc_ = global_preferences->make_preference(fnv1_hash("boost_time")); - uint8_t boost_time; - if (!this->boost_rtc_.load(&boost_time)) { - boost_time = DEFAULT_BOOST_TIME_SEC / 60; - } - auto call = this->boost_time_->make_call(); - call.set_value(boost_time); - call.perform(); - this->boost_time_->add_on_state_callback([this](float state) { - const uint8_t boost_time = state; - this->boost_rtc_.save(&boost_time); - }); - } -#endif -} - void TionComponent::update_dev_info_(const dentra::tion::tion_dev_info_t &info) { #ifdef USE_TION_VERSION if (this->version_ != nullptr) { diff --git a/components/tion/tion_component.h b/components/tion/tion_component.h index 36d3c8b..526c510 100644 --- a/components/tion/tion_component.h +++ b/components/tion/tion_component.h @@ -16,7 +16,6 @@ namespace esphome { namespace tion { class TionComponent : public PollingComponent { public: - void call_setup() override; #ifdef USE_TION_VERSION void set_version(text_sensor::TextSensor *version) { this->version_ = version; } #endif @@ -47,10 +46,6 @@ class TionComponent : public PollingComponent { void set_filter_warnout(binary_sensor::BinarySensor *filter_warnout) { this->filter_warnout_ = filter_warnout; } #endif -#ifdef TION_ENABLE_PRESETS - void set_boost_time(number::Number *boost_time) { this->boost_time_ = boost_time; } - void set_boost_time_left(sensor::Sensor *boost_time_left) { this->boost_time_left_ = boost_time_left; } -#endif #ifdef USE_TION_RESET_FILTER void set_reset_filter(button::Button *reset_filter) { this->reset_filter_ = reset_filter; }; void set_reset_filter_confirm(switch_::Switch *reset_filter_confirm) { @@ -124,12 +119,6 @@ class TionComponent : public PollingComponent { sensor::Sensor *work_time_{}; #endif -#ifdef TION_ENABLE_PRESETS - number::Number *boost_time_{}; - sensor::Sensor *boost_time_left_{}; - ESPPreferenceObject boost_rtc_; -#endif - void update_dev_info_(const dentra::tion::tion_dev_info_t &info); }; } // namespace tion diff --git a/components/tion/tion_defines.h b/components/tion/tion_defines.h new file mode 100644 index 0000000..fafaed6 --- /dev/null +++ b/components/tion/tion_defines.h @@ -0,0 +1,15 @@ +#pragma once + +#define TION_MIN_TEMPERATURE 1 + +#define TION_DEFAULT_MAX_TEMPERATURE 25 +#ifndef TION_MAX_TEMPERATURE +#define TION_MAX_TEMPERATURE TION_DEFAULT_MAX_TEMPERATURE +#endif +#if TION_MAX_TEMPERATURE > 30 || TION_MAX_TEMPERATURE < 20 +#define TION_MAX_TEMPERATURE TION_DEFAULT_MAX_TEMPERATURE +#endif + +#ifndef TION_MAX_FAN_SPEED +#define TION_MAX_FAN_SPEED 6 +#endif diff --git a/components/tion/tion_helpers.h b/components/tion/tion_helpers.h new file mode 100644 index 0000000..7a18f65 --- /dev/null +++ b/components/tion/tion_helpers.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/helpers.h" + +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate.h" +#endif + +namespace esphome { +namespace tion { + +inline uint8_t fan_mode_to_speed(const char *fan_mode) { return *fan_mode - '0'; } +inline uint8_t fan_mode_to_speed(const std::string &fan_mode) { return fan_mode_to_speed(fan_mode.c_str()); } +inline uint8_t fan_mode_to_speed(const optional &fan_mode) { + return fan_mode.has_value() ? fan_mode_to_speed(fan_mode.value()) : 0; +} + +inline std::string fan_speed_to_mode(uint8_t fan_speed) { + char fan_mode[2] = {static_cast(fan_speed + '0'), 0}; + return std::string(fan_mode); +} + +#ifdef USE_CLIMATE +enum TionClimateGatePosition : uint8_t { + TION_CLIMATE_GATE_POSITION_NONE = 0, + TION_CLIMATE_GATE_POSITION_OUTDOOR = 1, + TION_CLIMATE_GATE_POSITION_INDOOR = 2, + TION_CLIMATE_GATE_POSITION_MIXED = 3, + TION_CLIMATE_GATE_POSITION__LAST = 4, // NOLINT (bugprone-reserved-identifier) +}; + +#endif + +} // namespace tion +} // namespace esphome diff --git a/components/tion/tion_presets.cpp b/components/tion/tion_presets.cpp new file mode 100644 index 0000000..67fc6af --- /dev/null +++ b/components/tion/tion_presets.cpp @@ -0,0 +1,271 @@ +#ifdef TION_ENABLE_PRESETS +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +#ifdef USE_API +#include "esphome/components/api/custom_api_device.h" +#endif + +#include "tion_presets.h" + +namespace esphome { +namespace tion { + +static const char *const TAG = "tion_presets"; + +// boost time update interval +#define BOOST_TIME_UPDATE_INTERVAL_SEC 20 + +// application scheduler name +static const char *const ASH_BOOST = "tion-boost"; + +void TionPresets::setup_presets() { + if (this->boost_time_) { + this->boost_rtc_ = global_preferences->make_preference(fnv1_hash("boost_time")); + uint8_t boost_time; + if (!this->boost_rtc_.load(&boost_time)) { + boost_time = DEFAULT_BOOST_TIME_SEC / 60; + } + auto call = this->boost_time_->make_call(); + call.set_value(boost_time); + call.perform(); + this->boost_time_->add_on_state_callback([this](float state) { + const uint8_t boost_time = state; + this->boost_rtc_.save(&boost_time); + }); + } + +#ifdef USE_API + this->presets_rtc_ = + global_preferences->make_preference(fnv1_hash("presets")); + if (this->presets_rtc_.load(&this->presets_)) { + this->presets_[0].fan_speed = 0; // reset initialization + ESP_LOGD(TAG, "Presets loaded"); + } + api::CustomAPIDevice api; + api.register_service(&TionPresets::update_preset_service_, "update_preset", + {"preset", "mode", "fan_speed", "target_temperature", "gate_position"}); +#endif +} + +void TionPresets::add_presets(climate::ClimateTraits &traits) { + traits.add_supported_preset(climate::CLIMATE_PRESET_NONE); + this->for_each_preset_([&traits](auto index) { traits.add_supported_preset(index); }); +} + +#ifdef USE_API +// esphome services allows only pass copy of strings +void TionPresets::update_preset_service_(std::string preset_str, std::string mode_str, int32_t fan_speed, + int32_t target_temperature, std::string gate_position_str) { + climate::ClimatePreset preset = climate::ClimatePreset::CLIMATE_PRESET_NONE; + if (preset_str.length() > 0) { + auto preset_ch = std::tolower(preset_str[0]); + if (preset_ch == 'h') { // Home + preset = climate::ClimatePreset::CLIMATE_PRESET_HOME; + } else if (preset_ch == 'a') { // Away/Activity + if (preset_str.length() > 1) { + preset_ch = std::tolower(preset_str[1]); + if (preset_ch == 'w') { // aWay + preset = climate::ClimatePreset::CLIMATE_PRESET_AWAY; + } else if (preset_ch == 'c') { // aCtivity + preset = climate::ClimatePreset::CLIMATE_PRESET_ACTIVITY; + } + } + } else if (preset_ch == 'b') { // Boost + preset = climate::ClimatePreset::CLIMATE_PRESET_BOOST; + } else if (preset_ch == 'c') { // Comform + preset = climate::ClimatePreset::CLIMATE_PRESET_COMFORT; + } else if (preset_ch == 'e') { // Eco + preset = climate::ClimatePreset::CLIMATE_PRESET_ECO; + } else if (preset_ch == 's') { // Sleep + preset = climate::ClimatePreset::CLIMATE_PRESET_SLEEP; + } + } + + auto mode = climate::ClimateMode::CLIMATE_MODE_AUTO; + if (mode_str.length() > 0) { + auto mode_ch = std::tolower(mode_str[0]); + if (mode_ch == 'h') { // Heat + mode = climate::ClimateMode::CLIMATE_MODE_HEAT; + } else if (mode_ch == 'f') { // Fan_only + mode = climate::ClimateMode::CLIMATE_MODE_FAN_ONLY; + } else if (mode_ch == 'o') { // Off + mode = climate::ClimateMode::CLIMATE_MODE_OFF; + } + } + + TionClimateGatePosition gate_position = TION_CLIMATE_GATE_POSITION_NONE; + if (gate_position_str.length() > 0) { + auto gate_position_ch = std::tolower(gate_position_str[0]); + if (gate_position_ch == 'o') { // Outdoor + gate_position = TION_CLIMATE_GATE_POSITION_OUTDOOR; + } else if (gate_position_ch == 'i') { // Indoor + gate_position = TION_CLIMATE_GATE_POSITION_INDOOR; + } else if (gate_position_ch == 'm') { // Mixed + gate_position = TION_CLIMATE_GATE_POSITION_MIXED; + } + } + + if (this->update_preset(preset, mode, fan_speed, target_temperature, gate_position)) { + if (this->presets_rtc_.save(&this->presets_)) { + ESP_LOGCONFIG(TAG, "Preset was updated:"); + this->dump_preset_(TAG, preset); + } + } else { + ESP_LOGW(TAG, "Preset %s was't updated", preset_str.c_str()); + } +} +#endif // USE_API + +void TionPresets::dump_presets(const char *TAG) const { + LOG_NUMBER(" ", "Boost Time", this->boost_time_); + LOG_SENSOR(" ", "Boost Time Left", this->boost_time_left_); + auto has_presets = false; + this->for_each_preset_([&has_presets](auto index) { has_presets = true; }); + if (has_presets) { + ESP_LOGCONFIG(TAG, " Presets (fan_speed, target_temperature, mode, gate_position):"); + this->for_each_preset_([TAG, this](auto index) { this->dump_preset_(TAG, index); }); + } +} + +void TionPresets::dump_preset_(const char *tag, climate::ClimatePreset index) const { + auto gate_position_to_string = [](TionClimateGatePosition gp) -> const char * { + switch (gp) { + case TION_CLIMATE_GATE_POSITION_NONE: + return "none"; + case TION_CLIMATE_GATE_POSITION_OUTDOOR: + return "outdoor"; + case TION_CLIMATE_GATE_POSITION_INDOOR: + return "indoor"; + case TION_CLIMATE_GATE_POSITION_MIXED: + return "mixed"; + default: + return "unknown"; + } + }; + const auto &preset = this->presets_[index]; + const auto *preset_str = LOG_STR_ARG(climate::climate_preset_to_string(index)); + const auto *mode_str = LOG_STR_ARG(climate::climate_mode_to_string(preset.mode)); + const auto *gate_pos_str = gate_position_to_string(preset.gate_position); +#ifdef USE_ESP8266 + ESP_LOGCONFIG(tag, " %-8s: %u, %2d, %-8s, %s", preset_str, preset.fan_speed, preset.target_temperature, mode_str, + gate_pos_str); +#else + ESP_LOGCONFIG(tag, " %-8s: %u, %2d, %-8s, %s", str_lower_case(preset_str).c_str(), preset.fan_speed, + preset.target_temperature, str_lower_case(mode_str).c_str(), gate_pos_str); +#endif +} + +TionClimatePresetData *TionPresets::presets_enable_preset_(climate::ClimatePreset new_preset, Component *component, + climate::Climate *climate) { + const auto old_preset = *climate->preset; + if (new_preset == old_preset) { + ESP_LOGD(TAG, "Preset was not changed"); + return nullptr; + } + + if (new_preset >= TION_MAX_PRESETS) { + ESP_LOGW(TAG, "Unknown preset number %u", new_preset); + return nullptr; + } + + if (old_preset == climate::CLIMATE_PRESET_BOOST) { + ESP_LOGD(TAG, "Cancel preset boost"); + this->presets_cancel_boost_(component, climate); + } + + ESP_LOGD(TAG, "Enable preset %s", LOG_STR_ARG(climate::climate_preset_to_string(new_preset))); + if (new_preset == climate::CLIMATE_PRESET_BOOST) { + if (!this->presets_enable_boost_(component, climate)) { + return nullptr; + } + this->saved_preset_ = old_preset; + // инициализируем дефолный пресет NONE чтобы можно было в него восстановиться в любом случае + if (!this->presets_[climate::CLIMATE_PRESET_NONE].is_initialized() && old_preset != climate::CLIMATE_PRESET_NONE) { + this->update_default_preset_(climate); + } + } + + // если был пресет NONE, то сохраним его текущее состояние + if (old_preset == climate::CLIMATE_PRESET_NONE) { + this->update_default_preset_(climate); + } + + // дополнительно проверим, что пресет был предварительно сохранен (см. блок выше) + // в противном случае можем получить зимой, например, отстутсвие подогрева + // т.е. неинициализированный пресет не активируем + if (!this->presets_[new_preset].is_initialized()) { + ESP_LOGW(TAG, "No data for preset %s", LOG_STR_ARG(climate::climate_preset_to_string(new_preset))); + return nullptr; + } + + return &this->presets_[new_preset]; +} + +// TODO remove this method and use this->enable_preset_(this->saved_preset_); +TionClimatePresetData *TionPresets::presets_cancel_preset_(climate::ClimatePreset old_preset, Component *component, + climate::Climate *climate) { + if (old_preset == climate::CLIMATE_PRESET_BOOST) { + return this->presets_enable_preset_(this->saved_preset_, component, climate); + } + return nullptr; +} + +void TionPresets::update_default_preset_(climate::Climate *climate) { + this->presets_[climate::CLIMATE_PRESET_NONE].mode = climate->mode; + this->presets_[climate::CLIMATE_PRESET_NONE].target_temperature = climate->target_temperature; + this->presets_[climate::CLIMATE_PRESET_NONE].gate_position = this->get_gate_position(); + const auto fan_speed = fan_mode_to_speed(climate->custom_fan_mode); + if (fan_speed != 0) { + this->presets_[climate::CLIMATE_PRESET_NONE].fan_speed = fan_speed; + } +} + +bool TionPresets::presets_enable_boost_(Component *component, climate::Climate *climate) { + auto boost_time = this->get_boost_time_(); + if (boost_time == 0) { + ESP_LOGW(TAG, "Boost time is not configured"); + return false; + } + + // if boost_time_left not configured, just schedule stop boost after boost_time + if (this->boost_time_left_ == nullptr) { + ESP_LOGD(TAG, "Schedule boost timeout for %" PRIu32 " s", boost_time); + App.scheduler.set_timeout(component, ASH_BOOST, boost_time * 1000, [this, component, climate]() { + this->presets_cancel_preset_(climate::CLIMATE_PRESET_BOOST, component, climate); + }); + return true; + } + + // if boost_time_left is configured, schedule update it + ESP_LOGD(TAG, "Schedule boost interval up to %" PRIu32 " s", boost_time); + this->boost_time_left_->publish_state(static_cast(boost_time)); + + App.scheduler.set_interval(component, ASH_BOOST, BOOST_TIME_UPDATE_INTERVAL_SEC * 1000, [this, component, climate]() { + const int32_t time_left = static_cast(this->boost_time_left_->state) - BOOST_TIME_UPDATE_INTERVAL_SEC; + ESP_LOGV(TAG, "Boost time left %" PRId32 " s", time_left); + if (time_left > 0) { + this->boost_time_left_->publish_state(static_cast(time_left)); + } else { + this->presets_cancel_preset_(climate::CLIMATE_PRESET_BOOST, component, climate); + } + }); + + return true; +} + +void TionPresets::presets_cancel_boost_(Component *component, climate::Climate *climate) { + if (this->boost_time_left_) { + ESP_LOGV(TAG, "Cancel boost interval"); + App.scheduler.cancel_interval(component, ASH_BOOST); + this->boost_time_left_->publish_state(NAN); + } else { + ESP_LOGV(TAG, "Cancel boost timeout"); + App.scheduler.cancel_timeout(component, ASH_BOOST); + } +} + +} // namespace tion +} // namespace esphome +#endif // TION_ENABLE_PRESETS diff --git a/components/tion/tion_climate.h b/components/tion/tion_presets.h similarity index 60% rename from components/tion/tion_climate.h rename to components/tion/tion_presets.h index 3bf445b..3b5c927 100644 --- a/components/tion/tion_climate.h +++ b/components/tion/tion_presets.h @@ -2,59 +2,56 @@ #include "esphome/core/defines.h" -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" +#include "esphome/components/sensor/sensor.h" +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif + +#include "tion_defines.h" +#include "tion_helpers.h" namespace esphome { namespace tion { -enum TionClimateGatePosition : uint8_t { - TION_CLIMATE_GATE_POSITION_NONE = 0, - TION_CLIMATE_GATE_POSITION_OUTDOOR = 1, - TION_CLIMATE_GATE_POSITION_INDOOR = 2, - TION_CLIMATE_GATE_POSITION_MIXED = 3, - TION_CLIMATE_GATE_POSITION__LAST = 4, // NOLINT (bugprone-reserved-identifier) +#ifndef TION_ENABLE_PRESETS +class TionPresets { + public: + void dump_presets(const char *tag) const {} + void setup_presets() const {} + void add_presets(climate::ClimateTraits&traits)const{} }; +#endif + +} // namespace tion +} // namespace esphome #ifdef TION_ENABLE_PRESETS + // default boost time - 10 minutes #define DEFAULT_BOOST_TIME_SEC (10 * 60) -struct TionPreset { +#define TION_MAX_PRESETS (climate::CLIMATE_PRESET_ACTIVITY + 1) + +namespace esphome { +namespace tion { + +struct TionClimatePresetData { uint8_t fan_speed; int8_t target_temperature; climate::ClimateMode mode; TionClimateGatePosition gate_position; bool is_initialized() const { return this->fan_speed != 0; } + bool is_enabled() const { return this->mode != climate::CLIMATE_MODE_OFF; } } PACKED; -#define TION_MAX_PRESETS (climate::CLIMATE_PRESET_ACTIVITY + 1) -#endif // TION_ENABLE_PRESETS - -#define TION_MIN_TEMPERATURE 1 - -#define TION_DEFAULT_MAX_TEMPERATURE 25 -#ifndef TION_MAX_TEMPERATURE -#define TION_MAX_TEMPERATURE TION_DEFAULT_MAX_TEMPERATURE -#endif -#if TION_MAX_TEMPERATURE > 30 || TION_MAX_TEMPERATURE < 20 -#define TION_MAX_TEMPERATURE TION_DEFAULT_MAX_TEMPERATURE -#endif - -#ifndef TION_MAX_FAN_SPEED -#define TION_MAX_FAN_SPEED 6 -#endif - -class TionClimate : public climate::Climate { +class TionPresets { public: - TionClimate() { this->target_temperature = NAN; } - climate::ClimateTraits traits() override; - void control(const climate::ClimateCall &call) override; + void set_boost_time(number::Number *boost_time) { this->boost_time_ = boost_time; } + void set_boost_time_left(sensor::Sensor *boost_time_left) { this->boost_time_left_ = boost_time_left; } + + void setup_presets(); + void add_presets(climate::ClimateTraits&traits); - virtual void control_climate_state(climate::ClimateMode mode, uint8_t fan_speed, float target_temperature, - TionClimateGatePosition gate_position) = 0; - virtual TionClimateGatePosition get_gate_position() const = 0; -#ifdef TION_ENABLE_PRESETS /** * Update default preset. * @param preset preset to update. @@ -88,39 +85,32 @@ class TionClimate : public climate::Climate { return true; } -#endif // TION_ENABLE_PRESETS void dump_presets(const char *tag) const; - uint8_t get_fan_speed() const { return this->fan_mode_to_speed_(this->custom_fan_mode); } + virtual TionClimateGatePosition get_gate_position() const = 0; protected: - void set_fan_speed_(uint8_t fan_speed); - - uint8_t fan_mode_to_speed_(const optional &fan_mode) const { - if (fan_mode.has_value()) { - return *fan_mode.value().c_str() - '0'; - } - return 0; - } + number::Number *boost_time_{}; + sensor::Sensor *boost_time_left_{}; + ESPPreferenceObject boost_rtc_; + + bool presets_enable_boost_(Component *component, climate::Climate *climate); + void presets_cancel_boost_(Component *component, climate::Climate *climate); + + TionClimatePresetData *presets_enable_preset_(climate::ClimatePreset new_preset, Component *component, + climate::Climate *climate); + TionClimatePresetData *presets_cancel_preset_(climate::ClimatePreset old_preset, Component *component, + climate::Climate *climate); + +#ifdef USE_API + void update_preset_service_(std::string preset, std::string mode, int32_t fan_speed, int32_t target_temperature, + std::string gate_position); + ESPPreferenceObject presets_rtc_; +#endif - std::string fan_speed_to_mode_(uint8_t fan_speed) const { - char fan_mode[2] = {static_cast(fan_speed + '0'), 0}; - return std::string(fan_mode); - } -#ifdef TION_ENABLE_PRESETS - bool enable_preset_(climate::ClimatePreset new_preset); - void cancel_preset_(climate::ClimatePreset old_preset); - void update_default_preset_() { - this->presets_[climate::CLIMATE_PRESET_NONE].mode = this->mode; - this->presets_[climate::CLIMATE_PRESET_NONE].fan_speed = this->get_fan_speed(); - this->presets_[climate::CLIMATE_PRESET_NONE].target_temperature = this->target_temperature; - this->presets_[climate::CLIMATE_PRESET_NONE].gate_position = this->get_gate_position(); - } - virtual bool enable_boost() = 0; - virtual void cancel_boost() = 0; climate::ClimatePreset saved_preset_{climate::CLIMATE_PRESET_NONE}; - TionPreset presets_[TION_MAX_PRESETS] = { + TionClimatePresetData presets_[TION_MAX_PRESETS] = { {}, // NONE, saved data {.fan_speed = 2, .target_temperature = 20, @@ -154,17 +144,28 @@ class TionClimate : public climate::Climate { void for_each_preset_(const std::function &fn) const { for (size_t i = climate::CLIMATE_PRESET_NONE + 1; i < TION_MAX_PRESETS; i++) { - // "off" mean preset is disabled - if (this->presets_[i].mode != climate::CLIMATE_MODE_OFF) { + if (this->presets_[i].is_enabled()) { fn(static_cast(i)); } } } void dump_preset_(const char *tag, climate::ClimatePreset index) const; -#endif // TION_ENABLE_PRESETS + + void update_default_preset_(climate::Climate *climate); + + /// returns boost time in seconds. + uint32_t get_boost_time_() const { + if (this->boost_time_ == nullptr) { + return DEFAULT_BOOST_TIME_SEC; + } + if (this->boost_time_->traits.get_unit_of_measurement()[0] == 's') { + return this->boost_time_->state; + } + return this->boost_time_->state * 60; + } }; } // namespace tion } // namespace esphome -#endif // USE_CLIMATE +#endif // TION_ENABLE_PRESETS diff --git a/scripts/chk-compile b/scripts/chk-compile index 3a5457b..cf37449 100755 --- a/scripts/chk-compile +++ b/scripts/chk-compile @@ -2,7 +2,7 @@ prj_path=$(dirname $0)/.. -$prj_path/scripts/mk-config $1 +# $prj_path/scripts/mk-config $1 types=(lt-ble 4s-ble 4s-uart 3s-ble 3s-uart o2-uart) diff --git a/tests/_cloak/ESPAsyncTCP.h b/tests/_cloak/ESPAsyncTCP.h index 9101061..128cdc2 100644 --- a/tests/_cloak/ESPAsyncTCP.h +++ b/tests/_cloak/ESPAsyncTCP.h @@ -16,7 +16,7 @@ class AsyncClient : public cloak::Cloak { ~AsyncClient() { this->disconnect(); } bool connect(const char *host, uint16_t port); void disconnect(); - size_t add(const char *data, size_t size) { + size_t add(const char *data, size_t size, int flags = 0) { this->data_.insert(this->data_.end(), data, data + size); return size; } @@ -35,6 +35,7 @@ class AsyncClient : public cloak::Cloak { this->arg_data_ = arg; } bool connected() { return this->connected_; } + void close() { this->disconnect(); } void test_data_push(const void *data, size_t len); void test_data_push(const char *str) { this->test_data_push(str, strlen(str)); } diff --git a/tests/_cloak/ESPAsyncUDP.h b/tests/_cloak/ESPAsyncUDP.h index 5e179d7..8713c78 100644 --- a/tests/_cloak/ESPAsyncUDP.h +++ b/tests/_cloak/ESPAsyncUDP.h @@ -10,6 +10,15 @@ struct ip_addr_t { uint32_t addr; }; +inline void ipaddr_aton(const char *addr_in, ip_addr_t *addr_out) { addr_out->addr = 0; } + +struct IPAddress { + IPAddress(ip_addr_t addr) : ip(addr) {} + ip_addr_t ip{}; + std::string toString() { return "[not-implemented-addr-to-string]"; } + operator uint32_t() const { return ip.addr; } +}; + class AsyncUDPPacket { public: AsyncUDPPacket(const ip_addr_t &remote_ip, uint16_t remote_port, const uint8_t *data, size_t len) @@ -32,6 +41,7 @@ class AsyncUDP : public cloak::Cloak { public: bool connect(const ip_addr_t *addr, uint16_t port); bool listen(uint16_t port); + bool listenMulticast(const ip_addr_t *addr, uint16_t port); void onPacket(AuPacketHandlerFunction &&cb) { this->cb_packet_ = std::move(cb); } size_t writeTo(const uint8_t *data, size_t len, const ip_addr_t *addr, uint16_t port); @@ -39,6 +49,9 @@ class AsyncUDP : public cloak::Cloak { void test_data_push(const char *str) { this->test_data_push(str, strlen(str)); } void test_data_push(std::vector data) { this->test_data_push(data.data(), data.size()); } + void close() { this->connected_ = false; } + bool connected() const { return this->connected_; } + protected: bool connected_{}; ip_addr_t remote_ip_{}; diff --git a/tests/_cloak/esphome/components/switch/switch.h b/tests/_cloak/esphome/components/switch/switch.h index 2d3978d..bfce88b 100644 --- a/tests/_cloak/esphome/components/switch/switch.h +++ b/tests/_cloak/esphome/components/switch/switch.h @@ -26,6 +26,7 @@ namespace switch_ { class Switch : public EntityBase { public: + virtual ~Switch() {} bool state; void publish_state(bool state) { this->state = state; } bool is_inverted() const { return false; } diff --git a/tests/_cloak/runner.sh b/tests/_cloak/runner.sh index da17132..47cb408 100644 --- a/tests/_cloak/runner.sh +++ b/tests/_cloak/runner.sh @@ -16,7 +16,7 @@ if [ "$1" == "verbose" ]; then fi if [ -z "$PLATFORMIO_CORE_DIR" ]; then - export PLATFORMIO_CORE_DIR=$(realpath $(dirname $BASH_SOURCE)/../../.platformio) + export PLATFORMIO_CORE_DIR=$(pio system info --json-output|jq -r .core_dir.value) fi if [[ -z "${BUILD_DIR}" ]]; then diff --git a/tests/emu_t3s.py b/tests/emu_t3s.py new file mode 100644 index 0000000..cdc1257 --- /dev/null +++ b/tests/emu_t3s.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +# pylint: disable=missing-module-docstring +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring +# import time +import sys +import logging +from typing import Callable + +# from urllib import request +# import yaml + +import binascii +import abc +import struct +import signal +import time +import datetime +import serial + +_LOGGER = logging.getLogger() + +ONOFF = ["OFF", "ON"] +FAN_SPEED = ["OFF", "1", "2", "3", "4", "5", "6"] +GATE_POS = ["INFLOW", "RECIRCULATION"] + + +class Dongle: + @abc.abstractmethod + def write_cmd(self, cmd: int, data: str): + pass + + +class Device: + _reqs: dict[int, Callable] = {} + + def __init__(self, dongle: Dongle): + self._dongle = dongle + + @abc.abstractmethod + def get_name(self): + pass + + def _write_cmd(self, cmd: int, data: bytes): + self._dongle.write_cmd(cmd, data) + + def process_cmd(self, cmd: int, buf: bytes) -> bool: + if cmd in self._reqs: + self._reqs[cmd](buf) + return True + return False + + +class Tion4sState: + power_state: bool = True + sound_state: bool = True + led_state: bool = True + heater_state: bool = True + gate_pos: int = 0 + target_temp: int = 16 + fan_speed: int = 1 + + def dump(self): + _LOGGER.info( + "power: %s, sound: %s, led: %s, heater: %s, gate: %s, target_temp: %d°C, fan_speed: %s", + ONOFF[self.power_state], + ONOFF[self.sound_state], + ONOFF[self.led_state], + ONOFF[self.heater_state], + GATE_POS[self.gate_pos], + self.target_temp, + FAN_SPEED[self.fan_speed], + ) + + def pack(self, request_id) -> bytes: + flags = 0 + if self.power_state: + flags |= 1 << 0 + if self.sound_state: + flags |= 1 << 1 + if self.led_state: + flags |= 1 << 2 + if self.heater_state: + flags |= 1 << 3 + else: + flags |= 1 << 4 # heater_mode + return struct.pack( + " int: + fmt16 = " None: + self._write_cmd(self.CMD_HEARBEAT_RSP, b"\x01") + + def heartbeat_req(self, buf: bytes) -> None: # pylint: disable=unused-argument + _LOGGER.debug("heartbeat_req: %s", buf.hex(" ")) + self.heartbeat_rsp() + + def state_rsp(self, request_id) -> None: + self.state.dump() + self._write_cmd(self.CMD_STATE_RSP, self.state.pack(request_id)) + + def state_req(self, buf: bytes) -> None: # pylint: disable=unused-argument + _LOGGER.debug("state_req: %s", buf.hex(" ")) + request_id = struct.unpack("= 4 else 0 + self.state_rsp(request_id) + + def state_set(self, buf: bytes) -> None: + _LOGGER.debug("state_set: %s", buf.hex(" ")) + self.state_rsp(self.state.unpack(buf)) + + def dev_info_rsp(self) -> None: + data = struct.pack(" None: + _LOGGER.debug("dev_info: %s", buf.hex(" ")) + self.dev_info_rsp() + + def time_rsp(self, request_id: int) -> None: + now = datetime.datetime.now().replace(tzinfo=datetime.timezone.utc).timestamp() + self._write_cmd(self.CMD_TIME_RSP, struct.pack(" None: + _LOGGER.debug("time_req: %s", buf.hex(" ")) + request_id = struct.unpack("= 4 else 0 + self.time_rsp(request_id) + + def time_set(self, buf: bytes) -> None: + _LOGGER.debug("time_set: %s", buf.hex(" ")) + (request_id, unix_time) = struct.unpack(" 0: + try: + buf = self._rx_buf(buf + self._ser.read(available)) + except serial.SerialException as err: + _LOGGER.error(err) + break + time.sleep(1) + + def _rx_buf(self, buf: bytes) -> bytes: + """Process input buffer""" + _LOGGER.debug("RX: %s (%d)", buf.hex(" ").upper(), len(buf)) + max_buf_len = 0x2A + unk = bytearray() + while len(buf) > 0: + # _LOGGER.debug("buf: %s (%d)", buf.hex(" ").upper(), len(buf)) + if buf[0] == 0x3A: + if len(buf) < 3: + break # not enought data len + end = buf[2] << 8 | buf[1] + _LOGGER.debug("packet len: %d", end) + if end > max_buf_len: + _LOGGER.warning("packet len is too long: %d", end) + unk.append(buf[0]) + buf = buf[1:] + # sys.exit(1) + continue + if len(buf) < end: + break # not enought data len + self._rx_packet(buf[0:end]) + buf = buf[end:] + continue + + unk.append(buf[0]) + buf = buf[1:] + if len(unk) > 0: + _LOGGER.debug("Unknown bytes: %s (%d)", unk.hex(" ").upper(), len(unk)) + return buf + + def _rx_packet(self, buf: bytes) -> None: + _LOGGER.info("RX: %s (%d)", buf.hex(" ").upper(), len(buf)) + if binascii.crc_hqx(buf, 0xFFFF) != 0: + _LOGGER.warning("Invalid CRC") + return + cmd = buf[4] << 8 | buf[3] + buf = buf[5 : len(buf) - 2] + if not self._device.process_cmd(cmd, buf): + _LOGGER.warning( + "Unknown packet command: %02X, data: %s (%d)", + cmd, + buf.hex("."), + len(buf), + ) + + def write_cmd(self, cmd: int, data: bytes): + """Write command""" + buf = struct.pack("H", crc) + _LOGGER.info("TX: %s (%d)", buf.hex(" ").upper(), len(buf)) + if not self.is_test(): + self._ser.write(buf) + self._ser.flush() + time.sleep(0.1) + + def test(self, data: str): + if self.is_test(): + data = data.replace(" ", "").replace(".", "") + self._ser.write(bytes.fromhex(data)) + + def is_test(self) -> bool: + return self._ser.port.startswith("loop://") + + +def main(argv: list[str]): + logging.basicConfig(format="%(asctime)s %(levelname)-7s %(message)s") + _LOGGER.setLevel(logging.DEBUG) + emu = DongleEmu(argv[1] if len(argv) == 2 else "loop://?logging=info") + + def stop(signum, frame): # pylint: disable=unused-argument + print("\nStopping...") + emu.stop() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + + if emu.is_test(): + emu.test("3A 0700 3239 CEEC") + emu.test("3A 0700 3233 6FA6") + emu.test("3a 1200 3032 02000000 0600 00 0c 03 a4c5 76ca") + emu.test( + "3a 1200 3032 02000000 0600 00 0c 03 a4c5 76ca3A 0700 3239 CEEC3a 1200 3032 02000000 0600 00 0c 03 a4c5 76ca" + ) + emu.test( + "3A 20 00 31 33 01 03 80 00 00 D2 02 D2 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 20 64" + ) + + emu.run() + + +def ttt(buf: bytes): + (request_id, unix_time) = struct.unpack("> i) & 1) + '0'; + } + return bits_str_buf; +} + +#define DUMP_UNK(field) \ + if (state.field == 0 || state.field == 1) \ + ESP_LOGD(TAG, "%-12s: %u", #field, state.field); \ + else if (static_cast(state.field) > 0) \ + ESP_LOGD(TAG, "%-12s: 0x%02X, %s, %u", #field, state.field, bits_str(state.field), state.field); \ + else \ + ESP_LOGD(TAG, "%-12s: 0x%02X, %s, %u, %d", #field, state.field, bits_str(state.field), state.field, \ + static_cast(state.field)); + +bool test_api_o2_data() { + bool res = true; + // -25 °C, -30 °C, -35 °C, -40 °C + // for (int i = 25; i <= 40; i += 5) { + // printf("turn off temp: %d, 0x%02x\n", -i, ((uint16_t) -i) & 0xFF); + // } + // for (int i = 30; i <= 360; i += 30) { + // auto s = i * 24 * 3600; + // printf("max filter days: %3u, 0x%04x, 0x%08x\n", i, i, s); + // } + + for (auto hex : states) { + auto raw = cloak::from_hex(hex); + ESP_LOGD(TAG, "checking packet %s", hexencode_cstr(raw)); + auto state_ptr = reinterpret_cast(raw.data() + 1); + auto &state = *state_ptr; + ESP_LOGD(TAG, "flags : %s", bits_str(*reinterpret_cast(&state.flags))); + ESP_LOGD(TAG, "flags.power : %s", ONOFF(state.flags.power_state)); + ESP_LOGD(TAG, "flags.heater: %s", ONOFF(state.flags.heater_state)); + ESP_LOGD(TAG, "outdoor_temp: %d", state.outdoor_temperature); + ESP_LOGD(TAG, "current_temp: %d", state.current_temperature); + ESP_LOGD(TAG, "target_temp : %d", state.current_temperature); + ESP_LOGD(TAG, "fan_speed : %d", state.fan_speed); + ESP_LOGD(TAG, "productivity: %d", state.productivity); + DUMP_UNK(unknown7); + DUMP_UNK(unknown8); + DUMP_UNK(unknown9); + ESP_LOGD(TAG, "work_time : %" PRIu32, state.counters.work_time_days()); + ESP_LOGD(TAG, "filter_time : %" PRIu32, state.counters.filter_time_left()); + + printf("346 = 0x%08X\n", 346 * 24 * 3600); + printf("347 = 0x%08X\n", 347 * 24 * 3600); + } + + return res; +} + REGISTER_TEST(test_api_o2); REGISTER_TEST(test_api_o2_proxy); +REGISTER_TEST(test_api_o2_data); diff --git a/tests/test_nvs.cpp b/tests/test_nvs.cpp new file mode 100644 index 0000000..bfa1c2d --- /dev/null +++ b/tests/test_nvs.cpp @@ -0,0 +1,155 @@ + +#include +#include +#include +#include +#include "utils.h" +#include "../components/nvs/nvs.h" + +#include "http_parser.h" + +DEFINE_TAG; + +using namespace esphome::nvs_flash; + +bool test_nvs() { + bool res = true; + + NvsFlash nvs("test"); + + int32_t i32 = 0xFFFFFFFF; + nvs.set("i32", i32); + i32 = *nvs.get("i32"); + res &= cloak::check_data("i32", i32, -1); + + unsigned int _uint = 0xFFFFFFFF; + nvs.set("_uint", _uint); + res &= cloak::check_data("_uint", *nvs.get("_uint"), _uint); + + float _float = 4294967295; + nvs.set("_float", _float); + res &= cloak::check_data("_float", *nvs.get("_float"), _float); + + double _double = _float; + nvs.set("_double", _double); + res &= cloak::check_data("_double", *nvs.get("_double"), _double); + + std::string _string = "abcd"; + nvs.set("_string", _string); + res &= cloak::check_data("_string", *nvs.get("_string"), _string); + + struct X { + void set_address(uint64_t x) { printf("OK %llu\n", x); } + } _tion_ble_client; + + auto tion_ble_client = &_tion_ble_client; + + uint64_t tion_mac_address = -1ULL; + nvs.set("tion_mac_address", tion_mac_address); + + auto sav = nvs.get("tion_mac_address"); + if (sav.has_value()) { + [&](uint64_t x) { tion_ble_client->set_address(x); }(sav.value()); + } + + return res; +} + +using esphome::parse_hex; + +void url_decode(char *str) { + char *ptr = str, buf; + for (; *str; str++, ptr++) { + if (*str == '%') { + str++; + if (parse_hex(str, 2, reinterpret_cast(&buf), 1) == 2) { + *ptr = buf; + str++; + } else { + str--; + *ptr = *str; + } + } else if (*str == '+') { + *ptr = ' '; + } else { + *ptr = *str; + } + } + *ptr = *str; +} + +const char buf[] = "/?param0=a+b"; + +std::optional get_opt(bool x) { + if (x) + return {buf}; + return {}; +} + +void enum_errors(uint32_t errors, const std::function &fn) { + if (errors == 0) { + return; + } + for (uint32_t i = 0; i <= 10; i++) { + uint32_t mask = 1 << i; + if ((errors & mask) == mask) { + fn(esphome::str_snprintf("EC%02" PRIu32, 4, i + 1)); + } + } + for (uint32_t i = 24; i <= 29; i++) { + uint32_t mask = 1 << i; + if ((errors & mask) == mask) { + fn(esphome::str_snprintf("WS%02" PRIu32, 4, i + 1)); + } + } +} +bool test_parser() { + bool res = true; + + std::string s; + + s = "https%3A%2F%2FHello%2BWorld%2B%253E%2Bhow%2Bare%2Byou%253F"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "https://Hello+World+%3E+how+are+you%3F"; + + s = "https%3A%2F%2FHello+World%2B%253E%2Bhow%2Bare%2Byou%253F"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "https://Hello World+%3E+how+are+you%3F"; + + s = "%3N%2F+%%+%+%2F%3"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "%3N/ %% % /%3"; + + s = "https://ru.wikipedia.org/wiki/%D0%A2%D1%80%D0%B0%D0%BD%D1%81%D0%BF%D0%B0%D0%B9%D0%BB%D0%B5%D1%80"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "https://ru.wikipedia.org/wiki/Транспайлер"; + + s = "%3D%3F%25%26%2A%40"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "=?%&*@"; + + s = "%D0%BC%D0%B0%D0%BC%D0%B0%20%D0%BC%D1%8B%D0%BB%D0%B0%20%D1%80%D0%B0%D0%BC%D1%83"; + url_decode(s.data()); + s = s.c_str(); + printf("%s\n", s.c_str()); + res &= s == "мама мыла раму"; + + std::string codes; + enum_errors(1 << 0 | 1 << 28, [&codes](auto code) { codes += (codes.empty() ? "" : ", ") + code; }); + printf("%s\n", codes.c_str()); + + return res; +} + +REGISTER_TEST(test_nvs); +REGISTER_TEST(test_parser);