diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2878cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.DS_Store +.esphome/ +build/ +build-artifacts/ +secrets.yaml +20*-local-command-*.txt +__pycache__/ +*.pyc +*.elf +*.map +*.o +*.a + +# Do not publish vendor firmware dumps or derived disassembly. +*stock*.bin +*dump*.bin +dreo_app.bin +dreo_chunks/ +dreo_analysis/ diff --git a/README.md b/README.md index af106ab..3a41c3d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ into OTA mode (hold the oscillation button for 5 seconds until the countdown end and visit http://192.168.0.1 and upload the file. I have tested this allows me to install a functioning ESPhome although I haven't tested the fan functionality - although that should be fine +There is also a Dreo 712S ESP32-C3 example in +[example-dreo-712s-esp32c3.yaml](example-dreo-712s-esp32c3.yaml), with model +notes in [docs/dreo-712s/readme.md](docs/dreo-712s/readme.md). Unlike the HTF018S +config, the 712S keeps the stock ESP32-C3 module and uses the stock-compatible +[dreo_partitions.csv](docs/dreo-712s/dreo_partitions.csv), so no hardware swap is needed. It +drives the local GPIO7 RGB strip, and maps the model-specific fan, main CCT +light, button-event debounce, timers, and diagnostic/probe entities. + ## Tested models | Model | Type | Module | Config | Status | @@ -25,6 +33,7 @@ although I haven't tested the fan functionality - although that should be fine | DR-HTF018S | Tower | MBL01 (original) | [example-dreo-htf018s-mbl01.yaml](example-dreo-htf018s-mbl01.yaml) | Tested ESPhome installs okay, not tested fan functionality. Reports welcome. | | DR-HTF018S | Tower | ESP32C3 | [example-dreo-htf018s-esp32c3.yaml](example-dreo-htf018s-esp32c3.yaml) | Tested, works perfectly. | | DR-HTF024S | Tower | MBL01 (original) | [example-dreo-htf024s-mbl01.yaml](example-dreo-htf024s-mbl01.yaml) | Untested but [may work](https://github.com/davidc/dreo-protocol/issues/1). | +| 712S | Ceiling | ESP32-C3 (stock) | [example-dreo-712s-esp32c3.yaml](example-dreo-712s-esp32c3.yaml) | Tested on one unit, works. Datapoint map differs from the HTF models - see [docs/dreo-712s/readme.md](docs/dreo-712s/readme.md). | Please report back successes or failures. Please be careful and always ensure you have a way to restore the original firmware if needed (e.g. UART), I accept no responsibility for bricked devices! diff --git a/components/dreo/__init__.py b/components/dreo/__init__.py index fb764f7..028015b 100644 --- a/components/dreo/__init__.py +++ b/components/dreo/__init__.py @@ -8,13 +8,24 @@ CODEOWNERS = ["@davidc"] CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" +CONF_LOCAL_DATAPOINTS = "local_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" +CONF_ON_BUTTON_EVENT = "on_button_event" CONF_DATAPOINT_TYPE = "datapoint_type" +CONF_STARTUP_HEARTBEAT_INTERVAL = "startup_heartbeat_interval" +CONF_MAX_INIT_RETRIES = "max_init_retries" +CONF_QUERY_VERSION_ON_SYNC = "query_version_on_sync" +CONF_WIFI_STATUS_ON_SYNC = "wifi_status_on_sync" +CONF_PERIODIC_WIFI_STATUS = "periodic_wifi_status" +CONF_BUTTON_EVENT_REPEAT_GUARD = "button_event_repeat_guard" dreo_ns = cg.esphome_ns.namespace("dreo") DreoDatapointType = dreo_ns.enum("DreoDatapointType", is_class=True) Dreo = dreo_ns.class_("Dreo", cg.Component, uart.UARTDevice) +DreoButtonEventTrigger = dreo_ns.class_( + "DreoButtonEventTrigger", automation.Trigger.template(cg.uint8) +) DPTYPE_ANY = "any" DPTYPE_BOOL = "bool" @@ -67,9 +78,20 @@ def assign_declare_id(value): cv.Schema( { cv.GenerateID(): cv.declare_id(Dreo), + cv.Optional( + CONF_STARTUP_HEARTBEAT_INTERVAL, default="15s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_MAX_INIT_RETRIES, default=5): cv.uint8_t, + cv.Optional(CONF_QUERY_VERSION_ON_SYNC, default=False): cv.boolean, + cv.Optional(CONF_WIFI_STATUS_ON_SYNC, default=False): cv.boolean, + cv.Optional(CONF_PERIODIC_WIFI_STATUS, default=False): cv.boolean, + cv.Optional( + CONF_BUTTON_EVENT_REPEAT_GUARD, default="0ms" + ): cv.time_period, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), + cv.Optional(CONF_LOCAL_DATAPOINTS): cv.ensure_list(cv.uint8_t), cv.Optional(CONF_ON_DATAPOINT_UPDATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -82,6 +104,13 @@ def assign_declare_id(value): }, extra_validators=assign_declare_id, ), + cv.Optional(CONF_ON_BUTTON_EVENT): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DreoButtonEventTrigger + ), + } + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -93,9 +122,26 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) + cg.add( + var.set_startup_heartbeat_interval( + config[CONF_STARTUP_HEARTBEAT_INTERVAL].total_milliseconds + ) + ) + cg.add(var.set_max_init_retries(config[CONF_MAX_INIT_RETRIES])) + cg.add(var.set_query_version_on_sync(config[CONF_QUERY_VERSION_ON_SYNC])) + cg.add(var.set_wifi_status_on_sync(config[CONF_WIFI_STATUS_ON_SYNC])) + cg.add(var.set_periodic_wifi_status(config[CONF_PERIODIC_WIFI_STATUS])) + cg.add( + var.set_button_event_repeat_guard( + config[CONF_BUTTON_EVENT_REPEAT_GUARD].total_milliseconds + ) + ) if CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS in config: for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) + if CONF_LOCAL_DATAPOINTS in config: + for dp in config[CONF_LOCAL_DATAPOINTS]: + cg.add(var.add_local_datapoint(dp)) for conf in config.get(CONF_ON_DATAPOINT_UPDATE, []): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], var, conf[CONF_SENSOR_DATAPOINT] @@ -103,3 +149,6 @@ async def to_code(config): await automation.build_automation( trigger, [(DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]], "x")], conf ) + for conf in config.get(CONF_ON_BUTTON_EVENT, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "x")], conf) diff --git a/components/dreo/automation.h b/components/dreo/automation.h index 7dae1d6..b26eddd 100644 --- a/components/dreo/automation.h +++ b/components/dreo/automation.h @@ -35,6 +35,11 @@ class DreoEnumDatapointUpdateTrigger final : public Trigger { explicit DreoEnumDatapointUpdateTrigger(Dreo *parent, uint8_t sensor_id); }; +class DreoButtonEventTrigger final : public Trigger { + public: + explicit DreoButtonEventTrigger(Dreo *parent) { + parent->register_button_listener([this](const DreoButtonEvent &event) { this->trigger(event.code); }); + } +}; } // namespace esphome::dreo - diff --git a/components/dreo/dreo.cpp b/components/dreo/dreo.cpp index be66570..ab6d603 100644 --- a/components/dreo/dreo.cpp +++ b/components/dreo/dreo.cpp @@ -1,21 +1,34 @@ #include "dreo.h" + #include "esphome/components/network/util.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#include +#include +#include +#include + namespace esphome::dreo { static const char *const TAG = "dreo"; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; -static const int MAX_RETRIES = 5; -// Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; void Dreo::setup() { - this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(DreoCommandType::HEARTBEAT); }); + this->last_status_ = "handshaking"; + // Unconditional: this heartbeat is what recovers a stalled handshake when the + // MCU comes up after the ESP. Gating it on INIT_DONE strands the component + // permanently once init_failed_ latches. + this->set_interval("heartbeat", 15000, [this] { + this->heartbeat(); + if (this->periodic_wifi_status_ && this->init_state_ == DreoInitState::INIT_DONE) + this->report_wifi_state(0x01); + }); + this->heartbeat(); } void Dreo::loop() { @@ -33,6 +46,16 @@ void Dreo::loop() { this->handle_char_(buf[i]); } } + + if (this->init_state_ != DreoInitState::INIT_DONE && !this->init_failed_ && + this->startup_heartbeat_interval_ > 0 && this->command_queue_.empty() && !this->expected_response_.has_value()) { + const uint32_t now = millis(); + if (now - this->last_startup_heartbeat_timestamp_ >= this->startup_heartbeat_interval_) { + this->last_startup_heartbeat_timestamp_ = now; + this->heartbeat(); + } + } + process_command_queue_(); } @@ -53,6 +76,8 @@ void Dreo::dump_config() { ESP_LOGCONFIG(TAG, " Datapoint %u: boolean (value: %s)", info.id, ONOFF(info.value_bool)); } else if (info.type == DreoDatapointType::INTEGER) { ESP_LOGCONFIG(TAG, " Datapoint %u: int value (value: %d)", info.id, info.value_int); + } else if (info.type == DreoDatapointType::STRING) { + ESP_LOGCONFIG(TAG, " Datapoint %u: string value (len: %zu)", info.id, info.value_string.size()); } else if (info.type == DreoDatapointType::ENUM) { ESP_LOGCONFIG(TAG, " Datapoint %u: enum (value: %d)", info.id, info.value_enum); } else { @@ -60,6 +85,8 @@ void Dreo::dump_config() { } } ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); + if (!this->mcu_version_.empty()) + ESP_LOGCONFIG(TAG, " MCU version: '%s'", this->mcu_version_.c_str()); } bool Dreo::validate_message_() { @@ -75,7 +102,6 @@ bool Dreo::validate_message_() { return new_byte == 0xAA; // Byte 2: VERSION - // no validation for the following fields: uint8_t version = data[2]; if (at == 2) return true; @@ -96,10 +122,8 @@ bool Dreo::validate_message_() { // Byte 6: LENGTH1 // Byte 7: LENGTH2 - if (at <= 7) { - // no validation for these fields + if (at <= 7) return true; - } uint16_t length = (uint16_t(data[6]) << 8) | (uint16_t(data[7])); @@ -118,12 +142,16 @@ bool Dreo::validate_message_() { return false; } + this->last_rx_frame_hex_ = hex_(this->rx_message_.data(), this->rx_message_.size()); + // valid message const uint8_t *message_data = data + 8; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + const size_t log_len = std::min(length, MAX_DATAPOINT_LOG_BYTES); char hex_buf[format_hex_pretty_size(MAX_DATAPOINT_LOG_BYTES)]; - ESP_LOGV(TAG, "Received Dreo: CMD=0x%02X VERSION=%u SEQUENCE=%u DATA=[%s] INIT_STATE=%u", command, version, sequence, - format_hex_pretty_to(hex_buf, message_data, length), static_cast(this->init_state_)); + ESP_LOGV(TAG, "Received Dreo: CMD=0x%02X VERSION=%u SEQUENCE=%u DATA=[%s%s] INIT_STATE=%u", command, version, + sequence, format_hex_pretty_to(hex_buf, message_data, log_len), length > log_len ? " ..." : "", + static_cast(this->init_state_)); #endif this->handle_command_(command, version, sequence, message_data, length); @@ -134,7 +162,10 @@ bool Dreo::validate_message_() { void Dreo::handle_char_(uint8_t c) { this->rx_message_.push_back(c); if (!this->validate_message_()) { + const bool restart = this->rx_message_.size() > 1 && c == 0x55; this->rx_message_.clear(); + if (restart) + this->rx_message_.push_back(0x55); } else { this->last_rx_char_timestamp_ = millis(); } @@ -142,23 +173,30 @@ void Dreo::handle_char_(uint8_t c) { void Dreo::handle_command_(uint8_t command, uint8_t version, uint8_t sequence, const uint8_t *buffer, size_t len) { DreoCommandType command_type = (DreoCommandType) command; + char status[24]; + std::snprintf(status, sizeof(status), "rx command 0x%02x", command); + this->last_status_ = status; if (this->expected_response_.has_value() && this->expected_response_ == command_type) { this->expected_response_.reset(); - this->command_queue_.erase(command_queue_.begin()); + if (!this->command_queue_.empty()) + this->command_queue_.erase(command_queue_.begin()); this->init_retries_ = 0; } switch (command_type) { case DreoCommandType::HEARTBEAT: - ESP_LOGV(TAG, "MCU Heartbeat (0x%02X)", buffer[0]); + ESP_LOGV(TAG, "MCU Heartbeat (0x%02X)", len > 0 ? buffer[0] : 0); this->protocol_version_ = version; - if (buffer[0] == 0) { + if (len > 0 && buffer[0] == 0) { ESP_LOGI(TAG, "MCU restarted"); this->init_state_ = DreoInitState::INIT_HEARTBEAT; } if (this->init_state_ == DreoInitState::INIT_HEARTBEAT) { this->init_state_ = DreoInitState::INIT_PRODUCT; + this->last_status_ = "synced"; + if (this->wifi_status_on_sync_) + this->report_wifi_state(0x01); this->send_empty_command_(DreoCommandType::PRODUCT_QUERY); } break; @@ -166,7 +204,7 @@ void Dreo::handle_command_(uint8_t command, uint8_t version, uint8_t sequence, c // check it is a valid string made up of printable characters bool valid = true; for (size_t i = 0; i < len; i++) { - if (!std::isprint(buffer[i])) { + if (!std::isprint(static_cast(buffer[i]))) { valid = false; break; } @@ -177,32 +215,44 @@ void Dreo::handle_command_(uint8_t command, uint8_t version, uint8_t sequence, c this->product_ = R"({"p":"INVALID"})"; } if (this->init_state_ == DreoInitState::INIT_PRODUCT) { + if (this->query_version_on_sync_) { + this->send_empty_command_(DreoCommandType::VERSION_QUERY); + this->init_state_ = DreoInitState::INIT_VERSION; + } else { + this->send_empty_command_(DreoCommandType::DATAPOINT_QUERY); + this->init_state_ = DreoInitState::INIT_DATAPOINT; + } + } + break; + } + case DreoCommandType::VERSION_QUERY: { + if (len > 0) + this->mcu_version_ = std::string(reinterpret_cast(buffer), len); + if (this->init_state_ == DreoInitState::INIT_VERSION) { this->send_empty_command_(DreoCommandType::DATAPOINT_QUERY); this->init_state_ = DreoInitState::INIT_DATAPOINT; } break; } case DreoCommandType::DATAPOINT_DELIVER: + if (len > 0) + this->handle_datapoints_(buffer, len); break; case DreoCommandType::DATAPOINT_REPORT: if (this->init_state_ == DreoInitState::INIT_DATAPOINT) { this->init_state_ = DreoInitState::INIT_DONE; + this->last_status_ = "initialized"; this->set_timeout("datapoint_dump", 1000, [this] { this->dump_config(); }); this->initialized_callback_.call(); } this->handle_datapoints_(buffer, len); - - // # if this was unsolicited, send a reply TODO - // The MCU doesn't seem to care that we don't ack its unsolicited reports, but we need to make sure this doesn't trigger a memory leak in it - // if (command_type == DreoCommandType::DATAPOINT_REPORT_SYNC) { - // this->send_command_( - // DreoCommand{.cmd = DreoCommandType::DATAPOINT_REPORT_ACK, .payload = std::vector{0x01}}); - // } break; case DreoCommandType::DATAPOINT_QUERY: break; - case DreoCommandType::DATAPOINT_CHANGE_NOTIFICATION: - ESP_LOGD(TAG, "MCU informed us of datapoints changing"); // contents make no sense, they don't include the dpId or value + case DreoCommandType::WIFI_STATE: + break; + case DreoCommandType::BUTTON_EVENT: + this->handle_button_event_(buffer, len); break; default: ESP_LOGE(TAG, "Invalid command (0x%02X) received", command); @@ -225,40 +275,58 @@ void Dreo::handle_datapoints_(const uint8_t *buffer, size_t len) { } datapoint.len = data_size; + datapoint.value_raw.assign(data, data + data_size); + bool parsed = true; switch (datapoint.type) { case DreoDatapointType::BOOLEAN: if (data_size != 1) { ESP_LOGW(TAG, "Datapoint %u has bad boolean len %zu", datapoint.id, data_size); - return; + parsed = false; + break; } - datapoint.value_bool = data[0]; + datapoint.value_bool = data[0] != 0; + datapoint.value_uint = datapoint.value_bool ? 1 : 0; ESP_LOGD(TAG, "Datapoint %u update to %s", datapoint.id, ONOFF(datapoint.value_bool)); break; case DreoDatapointType::INTEGER: - if (data_size != 4) { + if (data_size == 0 || data_size > 4) { ESP_LOGW(TAG, "Datapoint %u has bad integer len %zu", datapoint.id, data_size); - return; + parsed = false; + break; } - datapoint.value_uint = encode_uint32(data[0], data[1], data[2], data[3]); + for (size_t i = 0; i < data_size; i++) + datapoint.value_uint = (datapoint.value_uint << 8) | data[i]; + datapoint.value_int = static_cast(datapoint.value_uint); ESP_LOGD(TAG, "Datapoint %u update to %d", datapoint.id, datapoint.value_int); break; + case DreoDatapointType::STRING: + datapoint.value_string = std::string(reinterpret_cast(data), data_size); + ESP_LOGD(TAG, "Datapoint %u update to string len %zu", datapoint.id, datapoint.value_string.size()); + break; case DreoDatapointType::ENUM: if (data_size != 1) { ESP_LOGW(TAG, "Datapoint %u has bad enum len %zu", datapoint.id, data_size); - return; + parsed = false; + break; } datapoint.value_enum = data[0]; + datapoint.value_uint = datapoint.value_enum; + datapoint.value_int = datapoint.value_enum; ESP_LOGD(TAG, "Datapoint %u update to %d", datapoint.id, datapoint.value_enum); break; default: ESP_LOGW(TAG, "Datapoint %u has unknown type %#02hhX", datapoint.id, static_cast(datapoint.type)); - return; + parsed = false; + break; } len -= data_size + 5; buffer = data + data_size; + if (!parsed) + continue; + // drop update if datapoint is in ignore_mcu_datapoint_update list bool skip = false; for (auto i : this->ignore_mcu_update_on_datapoints_) { @@ -271,24 +339,38 @@ void Dreo::handle_datapoints_(const uint8_t *buffer, size_t len) { if (skip) continue; - // Update internal datapoints - bool found = false; - for (auto &other : this->datapoints_) { - if (other.id == datapoint.id) { - other = datapoint; - found = true; - } - } - if (!found) { - this->datapoints_.push_back(datapoint); - } + this->publish_datapoint_(datapoint); + } +} - // Run through listeners - for (auto &listener : this->listeners_) { - if (listener.datapoint_id == datapoint.id) - listener.on_datapoint(datapoint); - } +void Dreo::handle_button_event_(const uint8_t *buffer, size_t len) { + if (buffer == nullptr || len < 3) { + this->last_button_event_ = "malformed"; + ESP_LOGW(TAG, "Malformed button event len=%zu", len); + return; } + + const uint8_t code = buffer[2]; + const std::string name = button_name_(code); + char text[48]; + std::snprintf(text, sizeof(text), "0x%02x %s", code, name.c_str()); + this->last_button_event_ = text; + ESP_LOGD(TAG, "Remote/panel button event: %s", this->last_button_event_.c_str()); + + const uint32_t now = millis(); + if (this->button_event_repeat_guard_ > 0 && !this->last_rx_frame_hex_.empty() && + this->last_button_event_frame_hex_ == this->last_rx_frame_hex_ && + now - this->last_button_event_frame_timestamp_ < this->button_event_repeat_guard_) { + ESP_LOGD(TAG, "Ignoring repeated button event frame: %s", this->last_rx_frame_hex_.c_str()); + return; + } + + this->last_button_event_frame_hex_ = this->last_rx_frame_hex_; + this->last_button_event_frame_timestamp_ = now; + + DreoButtonEvent event{.code = code, .name = name}; + for (auto &listener : this->button_listeners_) + listener.on_button_event(event); } void Dreo::send_raw_command_(DreoCommand command) { @@ -305,6 +387,9 @@ void Dreo::send_raw_command_(DreoCommand command) { case DreoCommandType::PRODUCT_QUERY: this->expected_response_ = DreoCommandType::PRODUCT_QUERY; break; + case DreoCommandType::VERSION_QUERY: + this->expected_response_ = DreoCommandType::VERSION_QUERY; + break; case DreoCommandType::DATAPOINT_DELIVER: case DreoCommandType::DATAPOINT_QUERY: this->expected_response_ = DreoCommandType::DATAPOINT_REPORT; @@ -314,20 +399,32 @@ void Dreo::send_raw_command_(DreoCommand command) { } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + const size_t log_len = std::min(command.payload.size(), MAX_DATAPOINT_LOG_BYTES); char hex_buf[format_hex_pretty_size(MAX_DATAPOINT_LOG_BYTES)]; - ESP_LOGV(TAG, "Sending Dreo: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", static_cast(command.cmd), - version, format_hex_pretty_to(hex_buf, command.payload.data(), command.payload.size()), - static_cast(this->init_state_)); + ESP_LOGV(TAG, "Sending Dreo: CMD=0x%02X VERSION=%u DATA=[%s%s] INIT_STATE=%u", static_cast(command.cmd), + version, format_hex_pretty_to(hex_buf, command.payload.data(), log_len), + command.payload.size() > log_len ? " ..." : "", static_cast(this->init_state_)); #endif - this->write_array({0x55, 0xAA, version, sequence, (uint8_t) command.cmd, 0, len_hi, len_lo}); - if (!command.payload.empty()) - this->write_array(command.payload.data(), command.payload.size()); - - uint8_t checksum = 0x55 + 0xAA + version + sequence + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + std::vector frame; + frame.reserve(command.payload.size() + 9); + frame.push_back(0x55); + frame.push_back(0xAA); + frame.push_back(version); + frame.push_back(sequence); + frame.push_back(static_cast(command.cmd)); + frame.push_back(0); + frame.push_back(len_hi); + frame.push_back(len_lo); + frame.insert(frame.end(), command.payload.begin(), command.payload.end()); + + uint8_t checksum = 0; + for (auto byte : frame) + checksum += byte; + frame.push_back(checksum); + + this->last_tx_frame_hex_ = hex_(frame.data(), frame.size()); + this->write_array(frame.data(), frame.size()); } void Dreo::process_command_queue_() { @@ -341,18 +438,21 @@ void Dreo::process_command_queue_() { if (this->expected_response_.has_value() && delay > RECEIVE_TIMEOUT) { this->expected_response_.reset(); if (init_state_ != DreoInitState::INIT_DONE) { - if (++this->init_retries_ >= MAX_RETRIES) { + if (++this->init_retries_ >= this->max_init_retries_) { this->init_failed_ = true; + this->last_status_ = "initialization failed"; ESP_LOGE(TAG, "Initialization failed at init_state %u", static_cast(this->init_state_)); - this->command_queue_.erase(command_queue_.begin()); + if (!this->command_queue_.empty()) + this->command_queue_.erase(command_queue_.begin()); this->init_retries_ = 0; } } else { - this->command_queue_.erase(command_queue_.begin()); + if (!this->command_queue_.empty()) + this->command_queue_.erase(command_queue_.begin()); } } - // Left check of delay since last command in case there's ever a command sent by calling send_raw_command_ directly + // Left check of delay since last command in case there's ever a command sent by calling send_raw_command_ directly. if (delay > COMMAND_DELAY && !this->command_queue_.empty() && this->rx_message_.empty() && !this->expected_response_.has_value()) { this->send_raw_command_(command_queue_.front()); @@ -370,6 +470,25 @@ void Dreo::send_empty_command_(DreoCommandType command) { send_command_(DreoCommand{.cmd = command, .payload = std::vector{}}); } +void Dreo::heartbeat() { this->send_empty_command_(DreoCommandType::HEARTBEAT); } + +void Dreo::request_state() { + this->heartbeat(); + this->send_empty_command_(DreoCommandType::DATAPOINT_QUERY); +} + +void Dreo::query_product_info() { + this->send_empty_command_(DreoCommandType::PRODUCT_QUERY); + if (this->query_version_on_sync_) + this->send_empty_command_(DreoCommandType::VERSION_QUERY); + this->send_empty_command_(DreoCommandType::DATAPOINT_QUERY); +} + +void Dreo::query_mcu_version() { this->send_empty_command_(DreoCommandType::VERSION_QUERY); } + +void Dreo::report_wifi_state(uint8_t state) { + this->send_command_(DreoCommand{.cmd = DreoCommandType::WIFI_STATE, .payload = std::vector{0x00, state}}); +} void Dreo::set_boolean_datapoint_value(uint8_t datapoint_id, bool value) { this->set_numeric_datapoint_value_(datapoint_id, DreoDatapointType::BOOLEAN, value, 1, false); @@ -383,6 +502,10 @@ void Dreo::set_enum_datapoint_value(uint8_t datapoint_id, uint8_t value) { this->set_numeric_datapoint_value_(datapoint_id, DreoDatapointType::ENUM, value, 1, false); } +void Dreo::set_string_datapoint_value(uint8_t datapoint_id, const std::string &value) { + this->set_string_datapoint_value_(datapoint_id, value, false); +} + void Dreo::force_set_boolean_datapoint_value(uint8_t datapoint_id, bool value) { this->set_numeric_datapoint_value_(datapoint_id, DreoDatapointType::BOOLEAN, value, 1, true); } @@ -395,6 +518,51 @@ void Dreo::force_set_enum_datapoint_value(uint8_t datapoint_id, uint8_t value) { this->set_numeric_datapoint_value_(datapoint_id, DreoDatapointType::ENUM, value, 1, true); } +void Dreo::publish_boolean_datapoint_value(uint8_t datapoint_id, bool value) { + DreoDatapoint datapoint{}; + datapoint.id = datapoint_id; + datapoint.type = DreoDatapointType::BOOLEAN; + datapoint.len = 1; + datapoint.value_bool = value; + datapoint.value_uint = value ? 1 : 0; + datapoint.value_raw = {static_cast(value ? 1 : 0)}; + this->publish_datapoint_(datapoint); +} + +void Dreo::publish_integer_datapoint_value(uint8_t datapoint_id, uint32_t value) { + DreoDatapoint datapoint{}; + datapoint.id = datapoint_id; + datapoint.type = DreoDatapointType::INTEGER; + datapoint.len = 4; + datapoint.value_uint = value; + datapoint.value_int = static_cast(value); + datapoint.value_raw = {static_cast((value >> 24) & 0xff), static_cast((value >> 16) & 0xff), + static_cast((value >> 8) & 0xff), static_cast(value & 0xff)}; + this->publish_datapoint_(datapoint); +} + +void Dreo::publish_enum_datapoint_value(uint8_t datapoint_id, uint8_t value) { + DreoDatapoint datapoint{}; + datapoint.id = datapoint_id; + datapoint.type = DreoDatapointType::ENUM; + datapoint.len = 1; + datapoint.value_enum = value; + datapoint.value_uint = value; + datapoint.value_int = value; + datapoint.value_raw = {value}; + this->publish_datapoint_(datapoint); +} + +void Dreo::publish_string_datapoint_value(uint8_t datapoint_id, const std::string &value) { + DreoDatapoint datapoint{}; + datapoint.id = datapoint_id; + datapoint.type = DreoDatapointType::STRING; + datapoint.len = value.size(); + datapoint.value_string = value; + datapoint.value_raw.assign(value.begin(), value.end()); + this->publish_datapoint_(datapoint); +} + optional Dreo::get_datapoint_(uint8_t datapoint_id) { for (auto &datapoint : this->datapoints_) { if (datapoint.id == datapoint_id) @@ -403,9 +571,109 @@ optional Dreo::get_datapoint_(uint8_t datapoint_id) { return {}; } +optional Dreo::get_datapoint_(uint8_t datapoint_id) const { + for (auto &datapoint : this->datapoints_) { + if (datapoint.id == datapoint_id) + return datapoint; + } + return {}; +} + +optional Dreo::bool_state(int datapoint_id) const { + auto datapoint = this->get_datapoint_(dp_index_(datapoint_id)); + if (!datapoint.has_value()) + return {}; + if (datapoint->type == DreoDatapointType::BOOLEAN) + return datapoint->value_bool; + if (datapoint->type == DreoDatapointType::INTEGER || datapoint->type == DreoDatapointType::ENUM) + return datapoint->value_uint != 0; + return {}; +} + +optional Dreo::number_state(int datapoint_id) const { + auto datapoint = this->get_datapoint_(dp_index_(datapoint_id)); + if (!datapoint.has_value()) + return {}; + if (datapoint->type == DreoDatapointType::BOOLEAN) + return datapoint->value_bool ? 1.0f : 0.0f; + if (datapoint->type == DreoDatapointType::INTEGER) + return static_cast(datapoint->value_int); + if (datapoint->type == DreoDatapointType::ENUM) + return static_cast(datapoint->value_enum); + return {}; +} + +std::string Dreo::string_state(int datapoint_id) const { + auto datapoint = this->get_datapoint_(dp_index_(datapoint_id)); + if (!datapoint.has_value()) + return {}; + if (datapoint->type == DreoDatapointType::STRING) + return datapoint->value_string; + if (datapoint->type == DreoDatapointType::BOOLEAN) + return datapoint->value_bool ? "true" : "false"; + return to_string(datapoint->value_int); +} + +std::string Dreo::seen_dps_summary() const { + std::string out; + char item[64]; + for (const auto &datapoint : this->datapoints_) { + if (datapoint.type == DreoDatapointType::BOOLEAN) { + std::snprintf(item, sizeof(item), "%u:01=%u", datapoint.id, datapoint.value_bool ? 1 : 0); + } else if (datapoint.type == DreoDatapointType::STRING) { + std::snprintf(item, sizeof(item), "%u:03[%u]", datapoint.id, + static_cast(datapoint.value_string.size())); + } else if (datapoint.type == DreoDatapointType::ENUM) { + std::snprintf(item, sizeof(item), "%u:04=%u", datapoint.id, datapoint.value_enum); + } else { + std::snprintf(item, sizeof(item), "%u:02=%ld", datapoint.id, static_cast(datapoint.value_int)); + } + + const size_t extra = out.empty() ? 0 : 2; + if (out.size() + extra + std::strlen(item) > 240) { + if (!out.empty()) + out.append(", ..."); + break; + } + if (!out.empty()) + out.append(", "); + out.append(item); + } + return out; +} + void Dreo::set_numeric_datapoint_value_(uint8_t datapoint_id, DreoDatapointType datapoint_type, const uint32_t value, uint8_t length, bool forced) { ESP_LOGD(TAG, "Setting datapoint %u to %" PRIu32, datapoint_id, value); + // Local (virtual) datapoints live only in the ESP's cache - update it and + // notify listeners, but never send them to the MCU. + for (auto i : this->local_datapoints_) { + if (datapoint_id == i) { + // Change-gate local publishes like MCU sends: entity listeners typically + // respond with a call whose write_state runs on the NEXT loop tick (past + // any re-entrancy guard) and sets this datapoint again - publishing an + // unchanged value turns that into a self-sustaining loop at loop rate. + if (!forced) { + optional existing = this->get_datapoint_(datapoint_id); + if (existing.has_value() && existing->value_uint == value) { + ESP_LOGV(TAG, "Not publishing unchanged local value"); + return; + } + } + switch (datapoint_type) { + case DreoDatapointType::BOOLEAN: + this->publish_boolean_datapoint_value(datapoint_id, value != 0); + break; + case DreoDatapointType::ENUM: + this->publish_enum_datapoint_value(datapoint_id, static_cast(value)); + break; + default: + this->publish_integer_datapoint_value(datapoint_id, value); + break; + } + return; + } + } optional datapoint = this->get_datapoint_(datapoint_id); if (!datapoint.has_value()) { ESP_LOGW(TAG, "Setting unknown datapoint %u", datapoint_id); @@ -436,10 +704,38 @@ void Dreo::set_numeric_datapoint_value_(uint8_t datapoint_id, DreoDatapointType this->send_datapoint_command_(datapoint_id, datapoint_type, data); } +void Dreo::set_string_datapoint_value_(uint8_t datapoint_id, const std::string &value, bool forced) { + for (auto i : this->local_datapoints_) { + if (datapoint_id == i) { + // Same change gate as the numeric path (see set_numeric_datapoint_value_). + if (!forced) { + optional existing = this->get_datapoint_(datapoint_id); + if (existing.has_value() && existing->value_string == value) + return; + } + this->publish_string_datapoint_value(datapoint_id, value); + return; + } + } + optional datapoint = this->get_datapoint_(datapoint_id); + if (!datapoint.has_value()) { + ESP_LOGW(TAG, "Setting unknown datapoint %u", datapoint_id); + } else if (datapoint->type != DreoDatapointType::STRING) { + ESP_LOGE(TAG, "Attempt to set datapoint %u with incorrect type", datapoint_id); + return; + } else if (!forced && datapoint->value_string == value) { + ESP_LOGV(TAG, "Not sending unchanged value"); + return; + } + + std::vector data(value.begin(), value.end()); + this->send_datapoint_command_(datapoint_id, DreoDatapointType::STRING, data); +} + void Dreo::send_datapoint_command_(uint8_t datapoint_id, DreoDatapointType datapoint_type, std::vector data) { std::vector buffer; buffer.push_back(datapoint_id); - buffer.push_back(0); // Unknown (always 0) + buffer.push_back(0); // Unknown (always 0 on the HTF-018S; 712S accepts it too) buffer.push_back(static_cast(datapoint_type)); buffer.push_back(data.size() >> 8); buffer.push_back(data.size() >> 0); @@ -448,6 +744,28 @@ void Dreo::send_datapoint_command_(uint8_t datapoint_id, DreoDatapointType datap this->send_command_(DreoCommand{.cmd = DreoCommandType::DATAPOINT_DELIVER, .payload = buffer}); } +void Dreo::publish_datapoint_(const DreoDatapoint &datapoint) { + this->update_datapoint_(datapoint); + this->notify_datapoint_listeners_(datapoint); +} + +void Dreo::update_datapoint_(const DreoDatapoint &datapoint) { + for (auto &other : this->datapoints_) { + if (other.id == datapoint.id) { + other = datapoint; + return; + } + } + this->datapoints_.push_back(datapoint); +} + +void Dreo::notify_datapoint_listeners_(const DreoDatapoint &datapoint) { + for (auto &listener : this->listeners_) { + if (listener.datapoint_id == datapoint.id) + listener.on_datapoint(datapoint); + } +} + void Dreo::register_listener(uint8_t datapoint_id, const std::function &func) { auto listener = DreoDatapointListener{ .datapoint_id = datapoint_id, @@ -462,7 +780,68 @@ void Dreo::register_listener(uint8_t datapoint_id, const std::function &func) { + this->button_listeners_.push_back(DreoButtonEventListener{.on_button_event = func}); +} + +std::string Dreo::hex_(const uint8_t *data, size_t len) { + std::string out; + out.reserve(len * 3); + char byte_text[4]; + for (size_t i = 0; i < len; i++) { + if (i != 0) + out.push_back(' '); + std::snprintf(byte_text, sizeof(byte_text), "%02x", data[i]); + out.append(byte_text); + } + return out; +} + +std::string Dreo::button_name_(uint8_t code) { + switch (code) { + case 0x01: + return "fan-speed-1"; + case 0x02: + return "fan-speed-2"; + case 0x03: + return "fan-speed-3"; + case 0x04: + return "fan-speed-4"; + case 0x05: + return "fan-speed-5"; + case 0x06: + return "fan-speed-6"; + case 0x07: + return "fan-on-off"; + case 0x08: + return "brightness-down"; + case 0x09: + return "light-on-off"; + case 0x0a: + return "brightness-up"; + case 0x0b: + return "fan-reverse-direction"; + case 0x0d: + return "kelvin-change"; + case 0x0f: + return "timer-1h"; + case 0x10: + return "timer-4h"; + case 0x11: + return "timer-8h"; + case 0x12: + return "rgb-change"; + default: + return "unknown"; + } +} + +uint8_t Dreo::dp_index_(int datapoint_id) { + if (datapoint_id < 0) + return static_cast(static_cast(datapoint_id)); + return static_cast(datapoint_id); +} + DreoInitState Dreo::get_init_state() { return this->init_state_; } } // namespace esphome::dreo - diff --git a/components/dreo/dreo.h b/components/dreo/dreo.h index 361223b..e45ce6d 100644 --- a/components/dreo/dreo.h +++ b/components/dreo/dreo.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "esphome/core/component.h" @@ -14,8 +16,8 @@ namespace esphome::dreo { enum class DreoDatapointType : uint8_t { // RAW = 0x00, // variable length BOOLEAN = 0x01, // 1 byte (0/1) - INTEGER = 0x02, // 4 byte - // STRING = 0x03, // variable length + INTEGER = 0x02, // 1/2/4 byte numeric value, model-dependent + STRING = 0x03, // variable length ENUM = 0x04, // 1 byte // BITMASK = 0x05, // 1/2/4 bytes }; @@ -30,6 +32,8 @@ struct DreoDatapoint { uint32_t value_uint; uint8_t value_enum; }; + std::string value_string; + std::vector value_raw; }; struct DreoDatapointListener { @@ -37,6 +41,15 @@ struct DreoDatapointListener { std::function on_datapoint; }; +struct DreoButtonEvent { + uint8_t code; + std::string name; +}; + +struct DreoButtonEventListener { + std::function on_button_event; +}; + enum class DreoCommandType : uint8_t { HEARTBEAT = 0x00, PRODUCT_QUERY = 0x01, @@ -45,7 +58,8 @@ enum class DreoCommandType : uint8_t { DATAPOINT_DELIVER = 0x06, DATAPOINT_REPORT = 0x07, DATAPOINT_QUERY = 0x08, - DATAPOINT_CHANGE_NOTIFICATION = 0x0E, // Can't decipher this so we'll ignore it + VERSION_QUERY = 0x09, + BUTTON_EVENT = 0x0E, }; @@ -54,6 +68,7 @@ enum class DreoCommandType : uint8_t { enum class DreoInitState : uint8_t { INIT_HEARTBEAT = 0x00, INIT_PRODUCT, + INIT_VERSION, INIT_DATAPOINT, INIT_DONE, }; @@ -70,16 +85,49 @@ class Dreo final : public Component, public uart::UARTDevice { void loop() override; void dump_config() override; void register_listener(uint8_t datapoint_id, const std::function &func); + void register_button_listener(const std::function &func); void set_boolean_datapoint_value(uint8_t datapoint_id, bool value); void set_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); void set_enum_datapoint_value(uint8_t datapoint_id, uint8_t value); + void set_string_datapoint_value(uint8_t datapoint_id, const std::string &value); void force_set_boolean_datapoint_value(uint8_t datapoint_id, bool value); void force_set_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); void force_set_enum_datapoint_value(uint8_t datapoint_id, uint8_t value); + void publish_boolean_datapoint_value(uint8_t datapoint_id, bool value); + void publish_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); + void publish_enum_datapoint_value(uint8_t datapoint_id, uint8_t value); + void publish_string_datapoint_value(uint8_t datapoint_id, const std::string &value); + optional bool_state(int datapoint_id) const; + optional number_state(int datapoint_id) const; + std::string string_state(int datapoint_id) const; + std::string last_rx_frame_hex() const { return this->last_rx_frame_hex_; } + std::string last_tx_frame_hex() const { return this->last_tx_frame_hex_; } + std::string last_status() const { return this->last_status_; } + std::string last_button_event() const { return this->last_button_event_; } + std::string product() const { return this->product_; } + std::string mcu_version() const { return this->mcu_version_; } + std::string seen_dps_summary() const; + bool initialized() const { return this->init_state_ == DreoInitState::INIT_DONE; } + void heartbeat(); + void request_state(); + void query_product_info(); + void query_mcu_version(); + void report_wifi_state(uint8_t state = 0x01); DreoInitState get_init_state(); + void set_startup_heartbeat_interval(uint32_t startup_heartbeat_interval) { + this->startup_heartbeat_interval_ = startup_heartbeat_interval; + } + void set_max_init_retries(uint8_t max_init_retries) { this->max_init_retries_ = max_init_retries; } + void set_query_version_on_sync(bool query_version_on_sync) { this->query_version_on_sync_ = query_version_on_sync; } + void set_wifi_status_on_sync(bool wifi_status_on_sync) { this->wifi_status_on_sync_ = wifi_status_on_sync; } + void set_periodic_wifi_status(bool periodic_wifi_status) { this->periodic_wifi_status_ = periodic_wifi_status; } + void set_button_event_repeat_guard(uint32_t button_event_repeat_guard) { + this->button_event_repeat_guard_ = button_event_repeat_guard; + } void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); } + void add_local_datapoint(uint8_t local_datapoint) { this->local_datapoints_.push_back(local_datapoint); } template void add_on_initialized_callback(F &&callback) { this->initialized_callback_.add(std::forward(callback)); } @@ -87,7 +135,9 @@ class Dreo final : public Component, public uart::UARTDevice { protected: void handle_char_(uint8_t c); void handle_datapoints_(const uint8_t *buffer, size_t len); + void handle_button_event_(const uint8_t *buffer, size_t len); optional get_datapoint_(uint8_t datapoint_id); + optional get_datapoint_(uint8_t datapoint_id) const; bool validate_message_(); void handle_command_(uint8_t command, uint8_t version, uint8_t sequence, const uint8_t *buffer, size_t len); @@ -97,24 +147,46 @@ class Dreo final : public Component, public uart::UARTDevice { void send_empty_command_(DreoCommandType command); void set_numeric_datapoint_value_(uint8_t datapoint_id, DreoDatapointType datapoint_type, uint32_t value, uint8_t length, bool forced); + void set_string_datapoint_value_(uint8_t datapoint_id, const std::string &value, bool forced); void send_datapoint_command_(uint8_t datapoint_id, DreoDatapointType datapoint_type, std::vector data); + void publish_datapoint_(const DreoDatapoint &datapoint); + void update_datapoint_(const DreoDatapoint &datapoint); + void notify_datapoint_listeners_(const DreoDatapoint &datapoint); + static std::string hex_(const uint8_t *data, size_t len); + static std::string button_name_(uint8_t code); + static uint8_t dp_index_(int datapoint_id); DreoInitState init_state_ = DreoInitState::INIT_HEARTBEAT; bool init_failed_{false}; int init_retries_{0}; + uint8_t max_init_retries_{5}; uint8_t protocol_version_ = -1; uint32_t last_command_timestamp_ = 0; uint32_t last_rx_char_timestamp_ = 0; + uint32_t last_startup_heartbeat_timestamp_ = 0; std::string product_; + std::string mcu_version_; std::vector listeners_; + std::vector button_listeners_; std::vector datapoints_; std::vector rx_message_; std::vector ignore_mcu_update_on_datapoints_{}; + std::vector local_datapoints_{}; std::vector command_queue_; optional expected_response_{}; CallbackManager initialized_callback_{}; + std::string last_rx_frame_hex_{}; + std::string last_tx_frame_hex_{}; + std::string last_status_{"not started"}; + std::string last_button_event_{}; + std::string last_button_event_frame_hex_{}; + uint32_t last_button_event_frame_timestamp_{0}; + uint32_t startup_heartbeat_interval_{15000}; + bool query_version_on_sync_{false}; + bool wifi_status_on_sync_{false}; + bool periodic_wifi_status_{false}; + uint32_t button_event_repeat_guard_{0}; uint8_t sequence_ = 0; }; } // namespace esphome::dreo - diff --git a/components/dreo/fan/__init__.py b/components/dreo/fan/__init__.py index 449e871..36096e6 100644 --- a/components/dreo/fan/__init__.py +++ b/components/dreo/fan/__init__.py @@ -3,16 +3,36 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT -from .. import CONF_DREO_ID, Dreo, dreo_ns +from .. import CONF_DREO_ID, Dreo, DreoDatapointType, dreo_ns DEPENDENCIES = ["dreo"] CONF_SPEED_DATAPOINT = "speed_datapoint" CONF_OSCILLATION_DATAPOINT = "oscillation_datapoint" CONF_DIRECTION_DATAPOINT = "direction_datapoint" +CONF_POWER_DATAPOINT = "power_datapoint" +CONF_SPEED_MAPPINGS = "speed_mappings" +CONF_SPEED_DATAPOINT_TYPE = "speed_datapoint_type" +CONF_DIRECTION_DATAPOINT_TYPE = "direction_datapoint_type" +CONF_DIRECTION_FORWARD_VALUE = "direction_forward_value" +CONF_DIRECTION_REVERSE_VALUE = "direction_reverse_value" DreoFan = dreo_ns.class_("DreoFan", cg.Component, fan.Fan) +DATAPOINT_TYPES = { + "bool": DreoDatapointType.BOOLEAN, + "int": DreoDatapointType.INTEGER, + "enum": DreoDatapointType.ENUM, +} + + +def validate_speed_mappings(config): + mappings = config.get(CONF_SPEED_MAPPINGS) + if mappings is not None and len(mappings) != config[CONF_SPEED_COUNT]: + raise cv.Invalid(f"{CONF_SPEED_MAPPINGS} length must match {CONF_SPEED_COUNT}") + return config + + CONFIG_SCHEMA = cv.All( fan.fan_schema(DreoFan) .extend( @@ -21,12 +41,23 @@ cv.Optional(CONF_OSCILLATION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_POWER_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256), + cv.Optional(CONF_SPEED_MAPPINGS): cv.ensure_list(cv.uint8_t), + cv.Optional(CONF_SPEED_DATAPOINT_TYPE): cv.enum( + DATAPOINT_TYPES, lower=True + ), + cv.Optional(CONF_DIRECTION_DATAPOINT_TYPE): cv.enum( + DATAPOINT_TYPES, lower=True + ), + cv.Optional(CONF_DIRECTION_FORWARD_VALUE): cv.uint32_t, + cv.Optional(CONF_DIRECTION_REVERSE_VALUE): cv.uint32_t, } ) .extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT), + validate_speed_mappings, ) @@ -41,8 +72,25 @@ async def to_code(config): cg.add(var.set_speed_id(config[CONF_SPEED_DATAPOINT])) if CONF_SWITCH_DATAPOINT in config: cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + if CONF_POWER_DATAPOINT in config: + cg.add(var.set_power_id(config[CONF_POWER_DATAPOINT])) if CONF_OSCILLATION_DATAPOINT in config: cg.add(var.set_oscillation_id(config[CONF_OSCILLATION_DATAPOINT])) if CONF_DIRECTION_DATAPOINT in config: cg.add(var.set_direction_id(config[CONF_DIRECTION_DATAPOINT])) - + if CONF_SPEED_MAPPINGS in config: + cg.add(var.set_speed_mappings(config[CONF_SPEED_MAPPINGS])) + if CONF_SPEED_DATAPOINT_TYPE in config: + cg.add(var.set_speed_type(config[CONF_SPEED_DATAPOINT_TYPE])) + if CONF_DIRECTION_DATAPOINT_TYPE in config: + cg.add(var.set_direction_type(config[CONF_DIRECTION_DATAPOINT_TYPE])) + if ( + CONF_DIRECTION_FORWARD_VALUE in config + and CONF_DIRECTION_REVERSE_VALUE in config + ): + cg.add( + var.set_direction_values( + config[CONF_DIRECTION_FORWARD_VALUE], + config[CONF_DIRECTION_REVERSE_VALUE], + ) + ) diff --git a/components/dreo/fan/dreo_fan.cpp b/components/dreo/fan/dreo_fan.cpp index db868e0..15da7ee 100644 --- a/components/dreo/fan/dreo_fan.cpp +++ b/components/dreo/fan/dreo_fan.cpp @@ -1,6 +1,8 @@ #include "esphome/core/log.h" #include "dreo_fan.h" +#include + namespace esphome::dreo { static const char *const TAG = "dreo.fan"; @@ -14,15 +16,16 @@ void DreoFan::setup() { this->parent_->register_listener(*speed_id, [this](const DreoDatapoint &datapoint) { if (datapoint.type == DreoDatapointType::ENUM) { ESP_LOGV(TAG, "MCU reported speed of: %d", datapoint.value_enum); - if (datapoint.value_enum > this->speed_count_) { + const int fan_speed = this->raw_speed_to_fan_speed_(datapoint.value_enum); + if (fan_speed > this->speed_count_) { ESP_LOGE(TAG, "Speed has invalid value %d", datapoint.value_enum); } else { - this->speed = datapoint.value_enum; + this->speed = fan_speed; this->publish_state(); } } else if (datapoint.type == DreoDatapointType::INTEGER) { ESP_LOGV(TAG, "MCU reported speed of: %d", datapoint.value_int); - this->speed = datapoint.value_int; + this->speed = this->raw_speed_to_fan_speed_(datapoint.value_uint); this->publish_state(); } this->speed_type_ = datapoint.type; @@ -51,8 +54,22 @@ void DreoFan::setup() { auto direction_id = this->direction_id_; if (direction_id.has_value()) { this->parent_->register_listener(*direction_id, [this](const DreoDatapoint &datapoint) { - ESP_LOGD(TAG, "MCU reported reverse direction is: %s", ONOFF(datapoint.value_bool)); - this->direction = datapoint.value_bool ? fan::FanDirection::REVERSE : fan::FanDirection::FORWARD; + this->direction_type_ = datapoint.type; + uint32_t value = 0; + if (datapoint.type == DreoDatapointType::INTEGER) { + value = datapoint.value_uint; + } else if (datapoint.type == DreoDatapointType::ENUM) { + value = datapoint.value_enum; + } else { + value = datapoint.value_bool ? 1 : 0; + } + ESP_LOGD(TAG, "MCU reported direction value is: %" PRIu32, value); + if (this->direction_reverse_value_.has_value()) { + this->direction = + value == *this->direction_reverse_value_ ? fan::FanDirection::REVERSE : fan::FanDirection::FORWARD; + } else { + this->direction = value ? fan::FanDirection::REVERSE : fan::FanDirection::FORWARD; + } this->publish_state(); }); } @@ -69,11 +86,20 @@ void DreoFan::dump_config() { auto speed_dp_id = this->speed_id_; if (speed_dp_id.has_value()) { ESP_LOGCONFIG(TAG, " Speed has datapoint ID %u", *speed_dp_id); + if (!this->speed_mappings_.empty()) { + ESP_LOGCONFIG(TAG, " Speed mappings:"); + for (size_t i = 0; i < this->speed_mappings_.size(); i++) + ESP_LOGCONFIG(TAG, " %zu -> %u", i + 1, this->speed_mappings_[i]); + } } auto switch_dp_id = this->switch_id_; if (switch_dp_id.has_value()) { ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *switch_dp_id); } + auto power_dp_id = this->power_id_; + if (power_dp_id.has_value()) { + ESP_LOGCONFIG(TAG, " Power has datapoint ID %u", *power_dp_id); + } auto oscillation_dp_id = this->oscillation_id_; if (oscillation_dp_id.has_value()) { ESP_LOGCONFIG(TAG, " Oscillation has datapoint ID %u", *oscillation_dp_id); @@ -81,6 +107,9 @@ void DreoFan::dump_config() { auto direction_dp_id = this->direction_id_; if (direction_dp_id.has_value()) { ESP_LOGCONFIG(TAG, " Direction has datapoint ID %u", *direction_dp_id); + if (this->direction_forward_value_.has_value() && this->direction_reverse_value_.has_value()) + ESP_LOGCONFIG(TAG, " Direction values: forward=%" PRIu32 ", reverse=%" PRIu32, *this->direction_forward_value_, + *this->direction_reverse_value_); } } @@ -94,6 +123,8 @@ void DreoFan::control(const fan::FanCall &call) { if (switch_id.has_value()) { auto state = call.get_state(); if (state.has_value()) { + if (*state && this->power_id_.has_value()) + this->parent_->set_boolean_datapoint_value(*this->power_id_, true); this->parent_->set_boolean_datapoint_value(*switch_id, *state); } } @@ -112,22 +143,55 @@ void DreoFan::control(const fan::FanCall &call) { if (dir_id.has_value()) { auto direction = call.get_direction(); if (direction.has_value()) { - bool enable = *direction == fan::FanDirection::REVERSE; - this->parent_->set_enum_datapoint_value(*dir_id, enable); + uint32_t value = *direction == fan::FanDirection::REVERSE ? 1 : 0; + if (*direction == fan::FanDirection::REVERSE && this->direction_reverse_value_.has_value()) + value = *this->direction_reverse_value_; + else if (*direction == fan::FanDirection::FORWARD && this->direction_forward_value_.has_value()) + value = *this->direction_forward_value_; + + if (this->direction_type_ == DreoDatapointType::INTEGER) { + this->parent_->set_integer_datapoint_value(*dir_id, value); + } else if (this->direction_type_ == DreoDatapointType::ENUM) { + this->parent_->set_enum_datapoint_value(*dir_id, value); + } else { + this->parent_->set_boolean_datapoint_value(*dir_id, value != 0); + } } } auto spd_id = this->speed_id_; if (spd_id.has_value()) { auto speed = call.get_speed(); if (speed.has_value()) { + const uint32_t raw_speed = this->fan_speed_to_raw_speed_(*speed); if (this->speed_type_ == DreoDatapointType::ENUM) { - this->parent_->set_enum_datapoint_value(*spd_id, *speed); + this->parent_->set_enum_datapoint_value(*spd_id, raw_speed); } else if (this->speed_type_ == DreoDatapointType::INTEGER) { - this->parent_->set_integer_datapoint_value(*spd_id, *speed); + this->parent_->set_integer_datapoint_value(*spd_id, raw_speed); } } } } -} // namespace esphome::dreo +int DreoFan::raw_speed_to_fan_speed_(uint32_t raw_speed) const { + if (this->speed_mappings_.empty()) + return raw_speed; + for (size_t i = 0; i < this->speed_mappings_.size(); i++) { + if (raw_speed <= this->speed_mappings_[i]) + return i + 1; + } + return this->speed_mappings_.size(); +} + +uint32_t DreoFan::fan_speed_to_raw_speed_(int fan_speed) const { + if (this->speed_mappings_.empty()) + return fan_speed; + + if (fan_speed < 1) + fan_speed = 1; + if (fan_speed > static_cast(this->speed_mappings_.size())) + fan_speed = this->speed_mappings_.size(); + return this->speed_mappings_[fan_speed - 1]; +} + +} // namespace esphome::dreo diff --git a/components/dreo/fan/dreo_fan.h b/components/dreo/fan/dreo_fan.h index 2efeaa6..827b9de 100644 --- a/components/dreo/fan/dreo_fan.h +++ b/components/dreo/fan/dreo_fan.h @@ -4,6 +4,8 @@ #include "esphome/components/dreo/dreo.h" #include "esphome/components/fan/fan.h" +#include + namespace esphome::dreo { class DreoFan final : public Component, public fan::Fan { @@ -13,23 +15,37 @@ class DreoFan final : public Component, public fan::Fan { void dump_config() override; void set_speed_id(uint8_t speed_id) { this->speed_id_ = speed_id; } void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } + void set_power_id(uint8_t power_id) { this->power_id_ = power_id; } void set_oscillation_id(uint8_t oscillation_id) { this->oscillation_id_ = oscillation_id; } void set_direction_id(uint8_t direction_id) { this->direction_id_ = direction_id; } + void set_speed_mappings(std::vector speed_mappings) { this->speed_mappings_ = std::move(speed_mappings); } + void set_speed_type(DreoDatapointType speed_type) { this->speed_type_ = speed_type; } + void set_direction_type(DreoDatapointType direction_type) { this->direction_type_ = direction_type; } + void set_direction_values(uint32_t forward_value, uint32_t reverse_value) { + this->direction_forward_value_ = forward_value; + this->direction_reverse_value_ = reverse_value; + } fan::FanTraits get_traits() override; protected: void control(const fan::FanCall &call) override; + int raw_speed_to_fan_speed_(uint32_t raw_speed) const; + uint32_t fan_speed_to_raw_speed_(int fan_speed) const; Dreo *parent_; optional speed_id_{}; optional switch_id_{}; + optional power_id_{}; optional oscillation_id_{}; optional direction_id_{}; int speed_count_{}; + std::vector speed_mappings_{}; DreoDatapointType speed_type_{}; DreoDatapointType oscillation_type_{}; + DreoDatapointType direction_type_{}; + optional direction_forward_value_{}; + optional direction_reverse_value_{}; }; } // namespace esphome::dreo - diff --git a/components/dreo/light/__init__.py b/components/dreo/light/__init__.py new file mode 100644 index 0000000..fc2c079 --- /dev/null +++ b/components/dreo/light/__init__.py @@ -0,0 +1,58 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLD_WHITE_COLOR_TEMPERATURE, + CONF_MAX_VALUE, + CONF_OUTPUT_ID, + CONF_SWITCH_DATAPOINT, + CONF_WARM_WHITE_COLOR_TEMPERATURE, +) + +from .. import CONF_DREO_ID, Dreo, dreo_ns + +DEPENDENCIES = ["dreo"] + +CONF_BRIGHTNESS_DATAPOINT = "brightness_datapoint" +CONF_COLOR_TEMPERATURE_DATAPOINT = "color_temperature_datapoint" + +DreoLight = dreo_ns.class_("DreoLight", light.LightOutput, cg.Component) + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(DreoLight), + cv.GenerateID(CONF_DREO_ID): cv.use_id(Dreo), + cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_BRIGHTNESS_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_COLOR_TEMPERATURE_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_MAX_VALUE, default=100): cv.int_, + cv.Optional( + CONF_COLD_WHITE_COLOR_TEMPERATURE, default="153 mireds" + ): cv.color_temperature, + cv.Optional( + CONF_WARM_WHITE_COLOR_TEMPERATURE, default="370 mireds" + ): cv.color_temperature, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.has_at_least_one_key(CONF_SWITCH_DATAPOINT, CONF_BRIGHTNESS_DATAPOINT), +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_DREO_ID]) + + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], parent) + await cg.register_component(var, config) + await light.register_light(var, config) + + if CONF_SWITCH_DATAPOINT in config: + cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + if CONF_BRIGHTNESS_DATAPOINT in config: + cg.add(var.set_brightness_id(config[CONF_BRIGHTNESS_DATAPOINT])) + if CONF_COLOR_TEMPERATURE_DATAPOINT in config: + cg.add(var.set_color_temperature_id(config[CONF_COLOR_TEMPERATURE_DATAPOINT])) + + cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) + cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/components/dreo/light/dreo_light.cpp b/components/dreo/light/dreo_light.cpp new file mode 100644 index 0000000..0e7bc03 --- /dev/null +++ b/components/dreo/light/dreo_light.cpp @@ -0,0 +1,114 @@ +#include + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "dreo_light.h" + +namespace esphome::dreo { + +static const char *const TAG = "dreo.light"; + +void DreoLight::setup() { + auto switch_id = this->switch_id_; + if (switch_id.has_value()) { + this->parent_->register_listener(*switch_id, [this](const DreoDatapoint &datapoint) { + if (this->state_ == nullptr) + return; + ESP_LOGV(TAG, "MCU reported light is: %s", ONOFF(datapoint.value_bool)); + auto call = this->state_->make_call(); + call.set_state(datapoint.value_bool); + this->publish_mcu_call_(call); + }); + } + auto brightness_id = this->brightness_id_; + if (brightness_id.has_value()) { + this->parent_->register_listener(*brightness_id, [this](const DreoDatapoint &datapoint) { + if (this->state_ == nullptr) + return; + ESP_LOGV(TAG, "MCU reported brightness of: %d", datapoint.value_int); + auto call = this->state_->make_call(); + call.set_brightness(clamp(static_cast(datapoint.value_int) / this->max_value_, 0.0f, 1.0f)); + this->publish_mcu_call_(call); + }); + } + auto color_temperature_id = this->color_temperature_id_; + if (color_temperature_id.has_value()) { + this->parent_->register_listener(*color_temperature_id, [this](const DreoDatapoint &datapoint) { + if (this->state_ == nullptr) + return; + ESP_LOGV(TAG, "MCU reported colour temperature of: %d", datapoint.value_int); + // Stock datapoint is 0..max_value where higher is cooler (higher Kelvin / + // lower mireds). Map it back onto the configured mired range. + float fraction = clamp(static_cast(datapoint.value_int) / this->max_value_, 0.0f, 1.0f); + float mireds = this->warm_white_temperature_ - fraction * (this->warm_white_temperature_ - this->cold_white_temperature_); + auto call = this->state_->make_call(); + call.set_color_temperature(mireds); + this->publish_mcu_call_(call); + }); + } +} + +void DreoLight::dump_config() { + ESP_LOGCONFIG(TAG, "Dreo Light"); + if (this->switch_id_.has_value()) + ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_); + if (this->brightness_id_.has_value()) + ESP_LOGCONFIG(TAG, " Brightness has datapoint ID %u", *this->brightness_id_); + if (this->color_temperature_id_.has_value()) + ESP_LOGCONFIG(TAG, " Colour temperature has datapoint ID %u", *this->color_temperature_id_); +} + +light::LightTraits DreoLight::get_traits() { + auto traits = light::LightTraits(); + if (this->color_temperature_id_.has_value()) { + traits.set_supported_color_modes({light::ColorMode::COLOR_TEMPERATURE}); + traits.set_min_mireds(this->cold_white_temperature_); + traits.set_max_mireds(this->warm_white_temperature_); + } else if (this->brightness_id_.has_value()) { + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + } else { + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + } + return traits; +} + +void DreoLight::write_state(light::LightState *state) { + if (this->updating_from_mcu_) + return; + + bool is_on = state->current_values.is_on(); + + if (this->switch_id_.has_value()) { + this->parent_->set_boolean_datapoint_value(*this->switch_id_, is_on); + } + + // Leave brightness/colour temperature alone while the light is off so we don't + // fight the stock latch; they are re-applied on the next turn-on. + if (!is_on) + return; + + if (this->brightness_id_.has_value()) { + float brightness = state->current_values.get_brightness(); + auto value = static_cast(roundf(brightness * this->max_value_)); + if (value == 0) + value = 1; // 0 would read as off on the stock scale + this->parent_->set_integer_datapoint_value(*this->brightness_id_, value); + } + + if (this->color_temperature_id_.has_value()) { + float mireds = state->current_values.get_color_temperature(); + float fraction = (this->warm_white_temperature_ - mireds) / + (this->warm_white_temperature_ - this->cold_white_temperature_); + fraction = clamp(fraction, 0.0f, 1.0f); + auto value = static_cast(roundf(fraction * this->max_value_)); + this->parent_->set_integer_datapoint_value(*this->color_temperature_id_, value); + } +} + +void DreoLight::publish_mcu_call_(light::LightCall &call) { + this->updating_from_mcu_ = true; + call.perform(); + this->updating_from_mcu_ = false; +} + +} // namespace esphome::dreo diff --git a/components/dreo/light/dreo_light.h b/components/dreo/light/dreo_light.h new file mode 100644 index 0000000..1f1db77 --- /dev/null +++ b/components/dreo/light/dreo_light.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/dreo/dreo.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::dreo { + +// A datapoint-driven light: on/off, brightness and (optionally) colour +// temperature are each backed by a single MCU datapoint. Modelled on the Dreo +// 712S main CCT light (DP4 on/off, DP8 brightness, DP9 colour temperature). +// +// This only covers lights the MCU actually controls over datapoints. A strip +// the ESP drives directly (e.g. the 712S ambient RGB on a GPIO) is a normal +// ESPHome light platform and does not belong here. +class DreoLight final : public Component, public light::LightOutput { + public: + DreoLight(Dreo *parent) : parent_(parent) {} + void setup() override; + void dump_config() override; + + void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } + void set_brightness_id(uint8_t brightness_id) { this->brightness_id_ = brightness_id; } + void set_color_temperature_id(uint8_t color_temperature_id) { this->color_temperature_id_ = color_temperature_id; } + void set_max_value(uint32_t max_value) { this->max_value_ = max_value; } + void set_cold_white_temperature(float cold_white_temperature) { + this->cold_white_temperature_ = cold_white_temperature; + } + void set_warm_white_temperature(float warm_white_temperature) { + this->warm_white_temperature_ = warm_white_temperature; + } + + light::LightTraits get_traits() override; + void setup_state(light::LightState *state) override { this->state_ = state; } + void write_state(light::LightState *state) override; + + protected: + void publish_mcu_call_(light::LightCall &call); + + Dreo *parent_; + light::LightState *state_{nullptr}; + bool updating_from_mcu_{false}; + optional switch_id_{}; + optional brightness_id_{}; + optional color_temperature_id_{}; + // Stock 712S brightness/colour-temperature datapoints run 0..100. + uint32_t max_value_{100}; + // Mireds at the two ends of the colour-temperature range. + float cold_white_temperature_{153.0f}; + float warm_white_temperature_{370.0f}; +}; + +} // namespace esphome::dreo diff --git a/components/dreo/select/dreo_select.cpp b/components/dreo/select/dreo_select.cpp index 758e147..db31d8f 100644 --- a/components/dreo/select/dreo_select.cpp +++ b/components/dreo/select/dreo_select.cpp @@ -7,7 +7,7 @@ static const char *const TAG = "dreo.select"; void DreoSelect::setup() { this->parent_->register_listener(this->select_id_, [this](const DreoDatapoint &datapoint) { - uint8_t enum_value = datapoint.value_enum; + uint8_t enum_value = datapoint.type == DreoDatapointType::INTEGER ? datapoint.value_uint : datapoint.value_enum; ESP_LOGV(TAG, "MCU reported select %u value %u", this->select_id_, enum_value); auto mappings = this->mappings_; auto it = std::find(mappings.cbegin(), mappings.cend(), enum_value); @@ -47,4 +47,3 @@ void DreoSelect::dump_config() { } } // namespace esphome::dreo - diff --git a/docs/dreo-712s/dreo_partitions.csv b/docs/dreo-712s/dreo_partitions.csv new file mode 100644 index 0000000..f2544a1 --- /dev/null +++ b/docs/dreo-712s/dreo_partitions.csv @@ -0,0 +1,11 @@ +# Dreo 712S stock-compatible partition table. +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x4000 +otadata, data, ota, 0xd000, 0x2000 +phy_init, data, phy, 0xf000, 0x1000 +ota_0, app, ota_0, 0x10000, 0x1A9000 +ota_1, app, ota_1, 0x1C0000, 0x1A9000 +fs, data, spiffs, 0x369000, 0x20000 +identity, data, 0x06, 0x389000, 0x1000 +nvs_1, data, nvs, 0x38A000, 0x10000 +effect, data, nvs, 0x39A000, 0x20000 diff --git a/docs/dreo-712s/readme.md b/docs/dreo-712s/readme.md new file mode 100644 index 0000000..5337aeb --- /dev/null +++ b/docs/dreo-712s/readme.md @@ -0,0 +1,99 @@ +# Dreo 712S + +Notes for adding the Dreo 712S (fan + light + ambient RGB) to this component. +Recovered from one unit; treat the datapoint map as model-specific. + +See [`example-dreo-712s-esp32c3.yaml`](../../example-dreo-712s-esp32c3.yaml) for a +full configuration. + +The example pulls the component from `github://davidc/dreo-protocol`, like the +other examples in this repo: + +```bash +esphome compile example-dreo-712s-esp32c3.yaml +``` + +It also needs the stock-compatible [`dreo_partitions.csv`](dreo_partitions.csv) +from this directory. The `partitions:` path in the YAML is resolved relative to +the config file, so keep the `docs/dreo-712s/` layout, or move the CSV next to +your config and change the path to match. + +To build against a local checkout instead (for testing changes to the component +before they are merged upstream), swap the `external_components` block for: + +```yaml +external_components: + - source: + type: local + path: components + components: [dreo] +``` + +## Hardware + +- WiFi module: ESP32-C3-WROOM-02U on a `CL187A_WIFI_V1.0` carrier board. +- Product MCU: SC95F8613B family 8051. +- MCU bridge UART: ESP `GPIO0` (TX) / `GPIO1` (RX), 115200 8N1. +- Ambient RGB: driven locally by the ESP on `GPIO7` (17 LEDs), **not** an MCU + datapoint. Use a normal ESPHome light platform for it. + +## Datapoint map + +| DP | Stock key | Type | Notes | +|---:|---|---|---| +| 1 | `poweron` | bool | Overall latch/diagnostic. Not a safe fan-only state. | +| 3 | `fanon` | bool | Fan on/off. | +| 4 | `lighton` | bool | Main white/CCT light on/off. | +| 5 | `atmon` | bool | Ambient RGB enable. | +| 6 | `mode` | int/value | Direction/mode. **1 = forward, 4 = reverse** (not 0/1). | +| 7 | `windlevel` | int/value | Fan level. Remote steps map to 1,3,5,7,9,12; accepts 1..12. | +| 8 | `brightness` | int | Main light brightness, 0..100. | +| 9 | `colortemp` | int | Main light colour temp, 0..100, **higher = cooler**. | +| 19 | `muteon` | bool | Inverted beep: `true` = quiet/muted. | +| 21 | `timeroff.du` | int | Timer-off duration, minutes (1h = 60). | +| 24 | `temperature` | int | Temperature-like reading (read-only). | +| 25 | `rgbpresetsel` | int/value | Ambient RGB preset select. | +| 27 | `atmbri` | int | Ambient RGB brightness, 0..100. | + +Other datapoints exist in the stock model: scene fields DP14-18, schedule DP22, +`predefine` DP28, and scene/effect/favorite string blobs at DP100-102. The +component can now parse string datapoints and exposes generic string setters for +template `text` entities. + +## Protocol notes specific to the 712S + +- **Command `0x09`** — an i4season-specific version-query helper sent during + startup in addition to the standard `0x01` product query. Enable + `query_version_on_sync: true` for the 712S. +- **Command `0x03`** — WiFi/network status. The stock 712S sends payload `00 01` + after sync; enable `wifi_status_on_sync: true`. +- **Command `0x0E` (button events)** — remote/panel presses with payload + `02 01 `. The component exposes `on_button_event` and an optional + `button_event_repeat_guard` for exact-frame deduplication: + + | Code | Button | Code | Button | + |---:|---|---:|---| + | `0x01`..`0x06` | fan speed 1..6 | `0x0a` | brightness up | + | `0x07` | fan on/off | `0x0b` | reverse direction | + | `0x08` | brightness down | `0x0d` | colour temp | + | `0x09` | light on/off | `0x12` | RGB change | + | `0x0f`/`0x10`/`0x11` | timer 1h/4h/8h | | | + + Some physical presses do not always emit a fresh datapoint report. The 712S + example mirrors fan speed, fan on/off, light on/off, and direction button + events into the local datapoint cache so Home Assistant state tracks the + physical remote without echoing a command back to the MCU. + +## 712S entity mapping + +- **Fan** is a normal `fan` entity. DP1 is asserted before fan-on commands, DP3 + is on/off, DP7 is speed, and DP6 is direction. The `speed_mappings` option + maps six HA speeds to stock levels `1,3,5,7,9,12`; `direction_forward_value: 1` + and `direction_reverse_value: 4` preserve the stock direction values. +- **Main light** is a datapoint-backed `light` entity: DP4 on/off, DP8 + brightness, DP9 colour temperature. DP9 is inverted from Home Assistant mireds: + higher raw values are cooler. +- **Ambient RGB** is a standard `esp32_rmt_led_strip` on GPIO7 with 17 LEDs. DP5, + DP25, and DP27 are still mirrored so remote/app preset state remains coherent. +- **Button beep** is inverted: DP19 `true` means muted/quiet, so the example + exposes a positive `Button Beep` switch. diff --git a/example-dreo-712s-esp32c3.yaml b/example-dreo-712s-esp32c3.yaml new file mode 100644 index 0000000..5782165 --- /dev/null +++ b/example-dreo-712s-esp32c3.yaml @@ -0,0 +1,1014 @@ +substitutions: + log_level: INFO + esp_idf_log_level: "3" + debug_entities_internal: "true" + +esphome: + name: dreo-712s + friendly_name: Dreo 712S + +external_components: + - source: github://davidc/dreo-protocol + +esp32: + board: esp32-c3-devkitm-1 + variant: esp32c3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_LOG_DEFAULT_LEVEL: ${esp_idf_log_level} + partitions: docs/dreo-712s/dreo_partitions.csv + +logger: + level: ${log_level} + baud_rate: 0 + +api: + +ota: + - platform: esphome + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + # Open fallback hotspot named " Fallback Hotspot", reachable via + # captive_portal below. reboot_timeout: 0s stops the reboot loop when no + # station is configured yet, so the hotspot stays up for provisioning. + ap: + reboot_timeout: 0s + +# web_server: +# port: 80 +# local: true + +captive_portal: + +uart: + id: mcu_uart + tx_pin: GPIO0 + rx_pin: GPIO1 + baud_rate: 115200 + data_bits: 8 + parity: NONE + stop_bits: 1 + rx_buffer_size: 512 + +dreo: + id: dreo_hub + uart_id: mcu_uart + startup_heartbeat_interval: 300ms + max_init_retries: 50 + # The 0x09 version query is an optional info helper, NOT a handshake gate: this + # unit's MCU never replies to it, so blocking sync on it (true) stalls init at + # INIT_VERSION forever. The stock/original firmware treats the first valid MCU + # frame as "synced" and fires 0x09 fire-and-forget afterwards. Keep it false. + query_version_on_sync: false + wifi_status_on_sync: true + periodic_wifi_status: false + # STOCK LOAD MODEL (from the stock app CBOR thing-model + live probing): + # DP1 = poweron (master), DP3 = fanon, DP4 = lighton. The load flags are + # INDEPENDENT writable booleans - fan and light can run together. Writing a + # load flag is all the stock app does ({fanon:true} => one DP3 write); the MCU + # raises/drops master DP1 itself as loads come and go. Display state is + # light = DP4 && DP1, fan = DP3 && DP1, from reliable MCU 0x07 reports (no + # dependency on the intermittently-dropped 0x0e button event). The routing + # interval publishes these to virtual DPs 210 (light) / 211 (fan) that the + # entities bind to; those are local-only and never sent to the MCU. + local_datapoints: [210, 211] + on_button_event: + - then: + - lambda: |- + // Remote off-presses sometimes arrive as this 0x0e button event with + // NO fresh DP report, leaving the cache stale. Re-query state on any + // button so the cache observes the transition promptly. + id(dreo_hub).request_state(); + // Ambient RGB remote button: short press = 0x12 (cycles preset, MCU + // drives DP5). Long hold = 0x14, which the MCU does NOT act on - the + // ESP is expected to turn RGB off (stock behaviour). Setting DP5=off + // lets the 500ms RGB interval switch the ESP LED strip off. + if (x == 0x14) { + id(rgb_manual_mode) = false; + id(dreo_hub).set_boolean_datapoint_value(5, false); + } + +fan: + - platform: dreo + id: main_fan + name: Fan + restore_mode: ALWAYS_OFF + # Display bound to virtual DP211 (published by the routing interval as + # DP3 && DP1). HA on/off writes the real DP3 to control it. + switch_datapoint: 211 + speed_datapoint: 7 + speed_datapoint_type: int + speed_count: 6 + speed_mappings: [1, 3, 5, 7, 9, 12] + direction_datapoint: 6 + direction_datapoint_type: int + direction_forward_value: 1 + direction_reverse_value: 4 + # Outbound control, stock-style: ONE write to the fan's own load flag (DP3), + # exactly like the stock app's {fanon:true/false}. The MCU manages master + # DP1 and the light itself - never write those here (a DP1=0 "off" would + # also kill the light, and bursts of writes 10ms apart get dropped by the + # MCU, which rate-limits at ~100ms). disp_fan is the last routing-published + # state: if it already matches, this on_turn came from routing/remote - not + # a user action - and we do nothing. + on_turn_on: + - lambda: |- + if (id(disp_fan)) return; // already on / routing-driven + id(pend_clear_d3) = false; + // force_: the cached DP3 may already read 1 (stale latch being + // suppressed) - a change-gated set would send nothing. + id(dreo_hub).force_set_boolean_datapoint_value(3, true); // fanon + // Hold the display at the commanded state and keep routing quiet + // until the MCU confirms (see fan_cmd_until_ms global). + id(disp_fan) = true; + id(fan_cmd_until_ms) = millis() + 1500; + on_turn_off: + - lambda: |- + if (!id(disp_fan)) return; // already off / routing-driven + id(pend_clear_d3) = false; + id(dreo_hub).force_set_boolean_datapoint_value(3, false); // fanon off + // The MCU takes ~1s to spin down and report DP3=0 - hold the display + // off so routing doesn't snap the HA toggle back ON meanwhile. + id(disp_fan) = false; + id(fan_cmd_until_ms) = millis() + 1500; + +light: + - platform: dreo + id: main_light + name: Light + # Display bound to virtual DP210 (published by the routing interval as + # DP4 && DP1 - the stock light_on). HA on/off writes the real DP4 to control + # it. Brightness (DP8) and colour (DP9) are honest and controlled directly. + switch_datapoint: 210 + brightness_datapoint: 8 + color_temperature_datapoint: 9 + max_value: 100 + cold_white_color_temperature: 153 mireds + warm_white_color_temperature: 500 mireds + restore_mode: ALWAYS_OFF + default_transition_length: 0s + # Outbound control, stock-style: ONE write to the light's own load flag + # (DP4), like the stock app's {lighton:true/false}. See the Fan handlers + # for the full rationale. disp_light is the last routing-published state; + # if it already matches, this on_turn came from routing/remote - skip. + on_turn_on: + - lambda: |- + if (id(disp_light)) return; // already on / routing-driven + id(pend_clear_d4) = false; + // force_: the cached DP4 may already read 1 (stale latch being + // suppressed) - a change-gated set would send nothing. + id(dreo_hub).force_set_boolean_datapoint_value(4, true); // lighton + // Hold the display at the commanded state and keep routing quiet + // until the MCU confirms (see light_cmd_until_ms global). + id(disp_light) = true; + id(light_cmd_until_ms) = millis() + 1500; + on_turn_off: + - lambda: |- + if (!id(disp_light)) return; // already off / routing-driven + id(pend_clear_d4) = false; + id(dreo_hub).force_set_boolean_datapoint_value(4, false); // lighton off + // Hold the display off until the MCU reports DP4=0 so routing + // doesn't snap the HA toggle back ON meanwhile. + id(disp_light) = false; + id(light_cmd_until_ms) = millis() + 1500; + + - platform: esp32_rmt_led_strip + id: ambient_rgb_strip + name: RGB Colour + pin: GPIO7 + num_leds: 17 + chipset: WS2812 + rgb_order: RGB + restore_mode: ALWAYS_OFF + default_transition_length: 0s + on_state: + - lambda: |- + if ((int32_t) (millis() - id(rgb_dp_sync_until_ms)) < 0) + return; + + id(rgb_manual_mode) = true; + bool desired_on = id(ambient_rgb_strip).remote_values.is_on(); + auto current_on = id(dreo_hub).bool_state(5); + if (!current_on.has_value() || current_on.value() != desired_on) + id(dreo_hub).set_boolean_datapoint_value(5, desired_on); + if (!desired_on) + return; + + float red = 0.0f; + float green = 0.0f; + float blue = 0.0f; + id(ambient_rgb_strip).remote_values.as_rgb(&red, &green, &blue); + float level = red; + if (green > level) + level = green; + if (blue > level) + level = blue; + int brightness = (int) (level * 100.0f + 0.5f); + if (brightness < 1) + brightness = 1; + if (brightness > 100) + brightness = 100; + + // ESP owns ambient brightness (no DP27 mirror write to the MCU). + id(atm_brightness) = brightness; + effects: + - addressable_rainbow: + name: RGB Rainbow + speed: 10 + width: 17 + - addressable_color_wipe: + name: RGB Color Wipe + add_led_interval: 80ms + colors: + - red: 100% + green: 0% + blue: 0% + num_leds: 17 + - red: 0% + green: 100% + blue: 0% + num_leds: 17 + - red: 0% + green: 0% + blue: 100% + num_leds: 17 + - addressable_scan: + name: RGB Scan + move_interval: 80ms + scan_width: 3 + +globals: + - id: probe_dp + type: int + restore_value: false + initial_value: "2" + - id: probe_val + type: int + restore_value: false + initial_value: "1" + - id: rgb_dp_sync_until_ms + type: uint32_t + restore_value: false + initial_value: "0" + - id: rgb_manual_mode + type: bool + restore_value: false + initial_value: "false" + # Ambient brightness store. The stock MCU never reports DP27 (confirmed from + # captures: state reports jump DP26 -> DP28), so ambient brightness is owned + # locally by the ESP rather than mirrored to a datapoint the MCU ignores. + - id: atm_brightness + type: int + restore_value: true + initial_value: "63" + # Last display value the routing interval published to the light/fan entities + # (i.e. the real physical state: light = DP4 && DP1, fan = DP3 && DP1). The + # on_turn handlers compare against these to tell a genuine HA command apart + # from a routing/remote-driven display update, so only real commands write DPs. + # Set BEFORE each publish so the value is correct whenever on_turn fires + # (whether synchronously or deferred to the next loop). + - id: disp_light + type: bool + restore_value: false + initial_value: "false" + - id: disp_fan + type: bool + restore_value: false + initial_value: "false" + # Previous-tick raw DP values + seed flag for the routing interval's + # rise-edge detection (stale-latch ghost cleanup, see the interval). + - id: routing_seeded + type: bool + restore_value: false + initial_value: "false" + - id: prev_p1 + type: bool + restore_value: false + initial_value: "false" + - id: prev_d3 + type: bool + restore_value: false + initial_value: "false" + - id: prev_d4 + type: bool + restore_value: false + initial_value: "false" + # A load flag was identified as a stale latch (see routing interval); keep + # writing it low (and suppress its display) until the MCU reports it cleared. + - id: pend_clear_d3 + type: bool + restore_value: false + initial_value: "false" + - id: pend_clear_d4 + type: bool + restore_value: false + initial_value: "false" + # Grace windows after a user-initiated on/off command: the MCU can take ~1s + # to apply and report a fan stop, so routing must not re-assert the stale + # flag in the meantime (the HA toggle would visibly snap back). While the + # window is open, disp_* holds the commanded value and routing stays quiet + # for that entity; expiry restores MCU-truth so a dropped write self-heals. + - id: light_cmd_until_ms + type: uint32_t + restore_value: false + initial_value: "0" + - id: fan_cmd_until_ms + type: uint32_t + restore_value: false + initial_value: "0" + +interval: + # Display routing: derive the honest per-load state from the MCU's reports + # (light = DP4 && DP1, fan = DP3 && DP1) and publish it to the local virtual + # DPs the entities bind to. Read-only - control writes happen in the fan and + # light on_turn handlers. + - interval: 250ms + then: + - lambda: |- + auto d1 = id(dreo_hub).bool_state(1); // master power (poweron) + auto d3 = id(dreo_hub).bool_state(3); // fanon load flag + auto d4 = id(dreo_hub).bool_state(4); // lighton load flag + bool p1 = d1.has_value() && d1.value(); + bool f3 = d3.has_value() && d3.value(); + bool l4 = d4.has_value() && d4.value(); + + // Stale-latch ghost cleanup. A remote off-press drops master DP1 but + // leaves the load's flag latched at 1. If the OTHER load then powers + // DP1 back up, the latched flag reads as on (flag && DP1) even though + // the MCU does not physically run a stale-latched load. Signature: + // DP1 rose together with exactly one flag, while the other flag was + // already high and displayed off -> that flag is stale; clear it in + // the MCU (retrying each tick until the report confirms) and suppress + // its display meanwhile. A flag that rose itself was commanded - keep. + if (!id(routing_seeded)) { + if (d1.has_value() || d3.has_value() || d4.has_value()) { + id(prev_p1) = p1; + id(prev_d3) = f3; + id(prev_d4) = l4; + id(routing_seeded) = true; + } + } else { + bool p1_rose = p1 && !id(prev_p1); + bool f3_rose = f3 && !id(prev_d3); + bool l4_rose = l4 && !id(prev_d4); + if (p1_rose && f3_rose && !l4_rose && l4 && !id(disp_light)) + id(pend_clear_d4) = true; + if (p1_rose && l4_rose && !f3_rose && f3 && !id(disp_fan)) + id(pend_clear_d3) = true; + id(prev_p1) = p1; + id(prev_d3) = f3; + id(prev_d4) = l4; + } + // At most one MCU write per tick (the MCU rate-limits at ~100ms and + // drops bursts). + bool wrote = false; + // Master-off latch normalization: with the master off, any load flag + // still latched 1 is invisible latent state - the MCU won't run the + // load, but a later power-up by the OTHER load would make it read as + // on (ghost). Clear latches whenever the master is off so power-ups + // always start clean, whatever caused the power-down and whenever + // its report arrives. + if (!p1) { + if (l4) { + id(dreo_hub).set_boolean_datapoint_value(4, false); + wrote = true; + } else if (f3) { + id(dreo_hub).set_boolean_datapoint_value(3, false); + wrote = true; + } + } + if (id(pend_clear_d4)) { + if (!l4) { + id(pend_clear_d4) = false; + } else if (!wrote) { + id(dreo_hub).set_boolean_datapoint_value(4, false); + wrote = true; + } + } + if (id(pend_clear_d3)) { + if (!f3) { + id(pend_clear_d3) = false; + } else if (!wrote) { + id(dreo_hub).set_boolean_datapoint_value(3, false); + wrote = true; + } + } + + // Stock model: independent load flags gated by master power. + // light = DP4 && DP1, fan = DP3 && DP1. Both from reliable MCU + // reports - no dependency on the intermittent button event. + bool light_val = l4 && p1 && !id(pend_clear_d4); + bool fan_val = f3 && p1 && !id(pend_clear_d3); + // A user command is in flight (grace window): the MCU flag is stale + // until it applies and reports (~1s for a fan stop), so don't + // re-assert it - disp_* keeps the commanded value set by the handler. + // On expiry MCU-truth resumes, so a dropped write self-heals. + bool light_grace = (int32_t) (millis() - id(light_cmd_until_ms)) < 0; + bool fan_grace = (int32_t) (millis() - id(fan_cmd_until_ms)) < 0; + // Record the display value BEFORE publishing so the light/fan on_turn + // guards see the correct physical state and don't fire a command back. + // Publish only when the cached virtual DP disagrees. The cache also + // catches optimistic write_state sets the MCU never confirmed, so a + // wrong optimistic value still gets corrected within one tick - + // without re-firing entity listeners 4x/sec when nothing changed. + if (!light_grace) { + id(disp_light) = light_val; + auto c210 = id(dreo_hub).bool_state(210); + if (!c210.has_value() || c210.value() != light_val) + id(dreo_hub).publish_boolean_datapoint_value(210, light_val); // light + } + if (!fan_grace) { + id(disp_fan) = fan_val; + auto c211 = id(dreo_hub).bool_state(211); + if (!c211.has_value() || c211.value() != fan_val) + id(dreo_hub).publish_boolean_datapoint_value(211, fan_val); // fan + } + + - interval: 500ms + then: + - lambda: |- + auto on = id(dreo_hub).bool_state(5); + if (!on.has_value()) + return; + + auto preset_state = id(dreo_hub).number_state(25); + int preset = preset_state.has_value() ? (int) preset_state.value() : 0; + if (preset < 0) + preset = 0; + if (preset > 4) + preset = 4; + + int brightness = id(atm_brightness); + if (brightness < 0) + brightness = 0; + if (brightness > 100) + brightness = 100; + + static bool have_last = false; + static bool last_on = false; + static int last_preset = -1; + static int last_brightness = -1; + bool next_on = on.value() && brightness > 0; + bool on_changed = have_last && next_on != last_on; + bool preset_changed = have_last && preset != last_preset; + bool brightness_changed = have_last && brightness != last_brightness; + + if (id(rgb_manual_mode) && !preset_changed) { + if (!have_last || on_changed || brightness_changed) { + id(rgb_dp_sync_until_ms) = millis() + 750; + if (!next_on) { + auto call = id(ambient_rgb_strip).turn_off(); + call.set_transition_length(0); + call.perform(); + } else { + auto call = id(ambient_rgb_strip).turn_on(); + call.set_transition_length(0); + call.set_brightness(brightness / 100.0f); + call.perform(); + } + } + have_last = true; + last_on = next_on; + last_preset = preset; + last_brightness = brightness; + return; + } + + if (id(rgb_manual_mode) && preset_changed) + id(rgb_manual_mode) = false; + + if (have_last && next_on == last_on && preset == last_preset && brightness == last_brightness) + return; + have_last = true; + last_on = next_on; + last_preset = preset; + last_brightness = brightness; + + id(rgb_dp_sync_until_ms) = millis() + 750; + if (!next_on) { + auto call = id(ambient_rgb_strip).turn_off(); + call.set_transition_length(0); + call.perform(); + return; + } + + auto call = id(ambient_rgb_strip).turn_on(); + call.set_transition_length(0); + call.set_brightness(brightness / 100.0f); + switch (preset) { + case 0: + call.set_effect("RGB Rainbow"); + break; + case 1: + call.set_effect((uint32_t) 0); + call.set_rgb(1.0f, 0.0f, 0.0f); + break; + case 2: + call.set_effect((uint32_t) 0); + call.set_rgb(0.0f, 1.0f, 0.0f); + break; + case 3: + call.set_effect((uint32_t) 0); + call.set_rgb(0.0f, 0.0f, 1.0f); + break; + default: + call.set_effect("RGB Color Wipe"); + break; + } + call.perform(); + +binary_sensor: + - platform: template + name: MCU Synced + entity_category: diagnostic + lambda: return id(dreo_hub).initialized(); + +button: + - platform: restart + name: Restart ESP + entity_category: diagnostic + + - platform: safe_mode + name: Restart ESP Safe Mode + entity_category: diagnostic + disabled_by_default: true + + - platform: factory_reset + name: Factory Reset ESP + entity_category: config + disabled_by_default: true + + - platform: template + name: Request MCU State + entity_category: diagnostic + internal: ${debug_entities_internal} + on_press: + - lambda: id(dreo_hub).request_state(); + + - platform: template + name: Query MCU Version + entity_category: diagnostic + internal: ${debug_entities_internal} + on_press: + - lambda: id(dreo_hub).query_mcu_version(); + + - platform: template + name: RGB Stock Next Preset + entity_category: config + on_press: + - lambda: |- + id(rgb_manual_mode) = false; + int next = 0; + auto on = id(dreo_hub).bool_state(5); + auto preset = id(dreo_hub).number_state(25); + if (on.has_value() && on.value() && preset.has_value()) + next = (int) preset.value() + 1; + if (next > 4) { + id(dreo_hub).set_boolean_datapoint_value(5, false); + id(dreo_hub).set_integer_datapoint_value(25, 0); + } else { + id(dreo_hub).set_boolean_datapoint_value(5, true); + if (id(atm_brightness) <= 0) + id(atm_brightness) = 63; + id(dreo_hub).set_integer_datapoint_value(25, next); + } + + - platform: template + name: Probe Write Bool + entity_category: diagnostic + internal: ${debug_entities_internal} + on_press: + - lambda: id(dreo_hub).set_boolean_datapoint_value((uint8_t) id(probe_dp), id(probe_val) != 0); + + - platform: template + name: Probe Write Enum8 + entity_category: diagnostic + internal: ${debug_entities_internal} + on_press: + - lambda: id(dreo_hub).set_enum_datapoint_value((uint8_t) id(probe_dp), (uint8_t) id(probe_val)); + + - platform: template + name: Probe Write U32 + entity_category: diagnostic + internal: ${debug_entities_internal} + on_press: + - lambda: id(dreo_hub).set_integer_datapoint_value((uint8_t) id(probe_dp), (uint32_t) id(probe_val)); + +switch: + - platform: template + name: Button Beep + entity_category: config + lambda: |- + auto muted = id(dreo_hub).bool_state(19); + if (!muted.has_value()) + return {}; + return !muted.value(); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(19, false); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(19, true); + + - platform: template + name: Power Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(1); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(1, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(1, false); + + - platform: template + name: Fan Enable Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(3); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(3, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(3, false); + + - platform: template + name: Light Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(4); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(4, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(4, false); + + - platform: template + name: Scene Uses Light + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(14); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(14, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(14, false); + + - platform: template + name: Ambient RGB Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(5); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(5, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(5, false); + + - platform: template + name: Schedule Enabled + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).bool_state(22); + turn_on_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(22, true); + turn_off_action: + - lambda: id(dreo_hub).set_boolean_datapoint_value(22, false); + +number: + - platform: template + name: Mode Value + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 20 + step: 1 + lambda: return id(dreo_hub).number_state(6); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(6, (uint32_t) x); + + - platform: template + name: Wind Level Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + mode: box + min_value: 1 + max_value: 12 + step: 1 + lambda: return id(dreo_hub).number_state(7); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(7, (uint32_t) x); + + - platform: template + name: Light Brightness Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + unit_of_measurement: "%" + mode: slider + min_value: 0 + max_value: 100 + step: 1 + lambda: return id(dreo_hub).number_state(8); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(8, (uint32_t) x); + + - platform: template + name: Light Color Temperature Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 100 + step: 1 + lambda: return id(dreo_hub).number_state(9); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(9, (uint32_t) x); + + - platform: template + name: Scene Mode + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 20 + step: 1 + lambda: return id(dreo_hub).number_state(15); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(15, (uint32_t) x); + + - platform: template + name: Scene Duration Seconds + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 86400 + step: 1 + lambda: return id(dreo_hub).number_state(16); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(16, (uint32_t) x); + + - platform: template + name: Scene Min Brightness + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 100 + step: 1 + lambda: return id(dreo_hub).number_state(17); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(17, (uint32_t) x); + + - platform: template + name: Scene Max Brightness + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 100 + step: 1 + lambda: return id(dreo_hub).number_state(18); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(18, (uint32_t) x); + + - platform: template + name: Timer On Duration Seconds + min_value: 0 + max_value: 86400 + step: 1 + lambda: return id(dreo_hub).number_state(20); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(20, (uint32_t) x); + + - platform: template + name: Timer Off Duration Seconds + min_value: 0 + max_value: 86400 + step: 1 + lambda: return id(dreo_hub).number_state(21); + set_action: + - lambda: id(dreo_hub).set_integer_datapoint_value(21, (uint32_t) x); + + - platform: template + name: Ambient RGB Brightness Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + unit_of_measurement: "%" + min_value: 0 + max_value: 100 + step: 1 + lambda: return id(atm_brightness); + set_action: + - lambda: id(atm_brightness) = (int) x; + + - platform: template + name: Probe DP ID + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 255 + step: 1 + lambda: return id(probe_dp); + set_action: + - lambda: id(probe_dp) = (int) x; + + - platform: template + name: Probe DP Value + entity_category: diagnostic + internal: ${debug_entities_internal} + min_value: 0 + max_value: 1000000 + step: 1 + lambda: return id(probe_val); + set_action: + - lambda: id(probe_val) = (int) x; + +select: + - platform: template + name: RGB Stock Preset + entity_category: config + internal: true + update_interval: 5s + options: + - "Off" + - "Preset 0" + - "Preset 1" + - "Preset 2" + - "Preset 3" + - "Preset 4" + lambda: |- + auto on = id(dreo_hub).bool_state(5); + if (on.has_value() && !on.value()) + return std::string("Off"); + auto preset = id(dreo_hub).number_state(25); + if (!preset.has_value()) + return {}; + int value = (int) preset.value(); + if (value < 0) + value = 0; + if (value > 4) + value = 4; + char name[16]; + snprintf(name, sizeof(name), "Preset %d", value); + return std::string(name); + set_action: + - lambda: |- + id(rgb_manual_mode) = false; + if (x == "Off") { + id(dreo_hub).set_boolean_datapoint_value(5, false); + id(dreo_hub).set_integer_datapoint_value(25, 0); + return; + } + id(dreo_hub).set_boolean_datapoint_value(5, true); + if (id(atm_brightness) <= 0) + id(atm_brightness) = 63; + int value = 0; + if (x == "Preset 1") + value = 1; + else if (x == "Preset 2") + value = 2; + else if (x == "Preset 3") + value = 3; + else if (x == "Preset 4") + value = 4; + id(dreo_hub).set_integer_datapoint_value(25, value); + +text: + - platform: template + name: Predefine Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + mode: TEXT + max_length: 255 + lambda: return id(dreo_hub).string_state(28); + set_action: + - lambda: id(dreo_hub).set_string_datapoint_value(28, x); + + - platform: template + name: Scenes Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + mode: TEXT + max_length: 255 + lambda: return id(dreo_hub).string_state(100); + set_action: + - lambda: id(dreo_hub).set_string_datapoint_value(100, x); + + - platform: template + name: Effect Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + mode: TEXT + max_length: 255 + lambda: return id(dreo_hub).string_state(101); + set_action: + - lambda: id(dreo_hub).set_string_datapoint_value(101, x); + + - platform: template + name: Favorite Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + mode: TEXT + max_length: 255 + lambda: return id(dreo_hub).string_state(102); + set_action: + - lambda: id(dreo_hub).set_string_datapoint_value(102, x); + +sensor: + - platform: template + name: Temperature Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).number_state(24); + unit_of_measurement: "°F" + update_interval: 10s + + - platform: dreo + id: current_temperature + name: Current Temperature + sensor_datapoint: 24 + device_class: temperature + unit_of_measurement: "°C" + accuracy_decimals: 1 + # DP24 is reported by the MCU in °F; convert to °C. + filters: + - lambda: return (x - 32.0) * 5.0 / 9.0; + + - platform: template + name: RGB Preset Raw + entity_category: diagnostic + internal: ${debug_entities_internal} + accuracy_decimals: 0 + lambda: return id(dreo_hub).number_state(25); + update_interval: 5s + + - platform: template + name: RGB Preset Count + entity_category: diagnostic + internal: ${debug_entities_internal} + accuracy_decimals: 0 + lambda: return id(dreo_hub).number_state(26); + update_interval: 10s + +text_sensor: + - platform: template + name: Product Info + entity_category: diagnostic + lambda: return id(dreo_hub).product(); + update_interval: 10s + + - platform: template + name: MCU Version Info + entity_category: diagnostic + lambda: return id(dreo_hub).mcu_version(); + update_interval: 10s + + - platform: template + name: MCU Firmware Version + # Parsed from Product Info: "004+CMS32/GL+2.1.39" -> "2.1.39". + lambda: |- + std::string p = id(dreo_hub).product(); + auto last = p.rfind('+'); + if (last == std::string::npos) + return std::string(""); + return p.substr(last + 1); + update_interval: 10s + + - platform: template + name: MCU Hardware Model + # Parsed from Product Info: "004+CMS32/GL+2.1.39" -> "CMS32/GL". + lambda: |- + std::string p = id(dreo_hub).product(); + auto first = p.find('+'); + auto last = p.rfind('+'); + if (first == std::string::npos || last == first) + return std::string(""); + return p.substr(first + 1, last - first - 1); + update_interval: 10s + + - platform: template + name: Last MCU Status + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).last_status(); + update_interval: 5s + + - platform: template + name: Last Remote Button + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).last_button_event(); + update_interval: 1s + + - platform: template + name: Last MCU RX Frame + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).last_rx_frame_hex(); + update_interval: 5s + + - platform: template + name: Last MCU TX Frame + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).last_tx_frame_hex(); + update_interval: 5s + + - platform: template + name: DP Map + entity_category: diagnostic + internal: ${debug_entities_internal} + lambda: return id(dreo_hub).seen_dps_summary(); + update_interval: 5s