From 8b2c032da6a30c049dd89218b3c533cccde63308 Mon Sep 17 00:00:00 2001 From: anatoly-savchenkov <48646998+anatoly-savchenkov@users.noreply.github.com> Date: Tue, 12 Apr 2022 08:03:32 +0300 Subject: [PATCH] Add Sonoff D1 Dimmer support (#2775) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/sonoff_d1/__init__.py | 1 + esphome/components/sonoff_d1/light.py | 43 +++ esphome/components/sonoff_d1/sonoff_d1.cpp | 308 +++++++++++++++++++++ esphome/components/sonoff_d1/sonoff_d1.h | 85 ++++++ tests/test3.yaml | 6 + 6 files changed, 444 insertions(+) create mode 100644 esphome/components/sonoff_d1/__init__.py create mode 100644 esphome/components/sonoff_d1/light.py create mode 100644 esphome/components/sonoff_d1/sonoff_d1.cpp create mode 100644 esphome/components/sonoff_d1/sonoff_d1.h diff --git a/CODEOWNERS b/CODEOWNERS index 5a1220354a..79626c4a38 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -177,6 +177,7 @@ esphome/components/shutdown/* @esphome/core @jsuanet esphome/components/sim800l/* @glmnet esphome/components/sm2135/* @BoukeHaarsma23 esphome/components/socket/* @esphome/core +esphome/components/sonoff_d1/* @anatoly-savchenkov esphome/components/spi/* @esphome/core esphome/components/ssd1322_base/* @kbx81 esphome/components/ssd1322_spi/* @kbx81 diff --git a/esphome/components/sonoff_d1/__init__.py b/esphome/components/sonoff_d1/__init__.py new file mode 100644 index 0000000000..18b4d30d18 --- /dev/null +++ b/esphome/components/sonoff_d1/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@anatoly-savchenkov"] diff --git a/esphome/components/sonoff_d1/light.py b/esphome/components/sonoff_d1/light.py new file mode 100644 index 0000000000..8ffe60224e --- /dev/null +++ b/esphome/components/sonoff_d1/light.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import uart, light +from esphome.const import ( + CONF_OUTPUT_ID, + CONF_MIN_VALUE, + CONF_MAX_VALUE, +) + +CONF_USE_RM433_REMOTE = "use_rm433_remote" + +DEPENDENCIES = ["uart", "light"] + +sonoff_d1_ns = cg.esphome_ns.namespace("sonoff_d1") +SonoffD1Output = sonoff_d1_ns.class_( + "SonoffD1Output", cg.Component, uart.UARTDevice, light.LightOutput +) + +CONFIG_SCHEMA = ( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(SonoffD1Output), + cv.Optional(CONF_USE_RM433_REMOTE, default=False): cv.boolean, + cv.Optional(CONF_MIN_VALUE, default=0): cv.int_range(min=0, max=100), + cv.Optional(CONF_MAX_VALUE, default=100): cv.int_range(min=0, max=100), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sonoff_d1", baud_rate=9600, require_tx=True, require_rx=True +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add(var.set_use_rm433_remote(config[CONF_USE_RM433_REMOTE])) + cg.add(var.set_min_value(config[CONF_MIN_VALUE])) + cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + await light.register_light(var, config) diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp new file mode 100644 index 0000000000..b4bcbc6760 --- /dev/null +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -0,0 +1,308 @@ +/* + sonoff_d1.cpp - Sonoff D1 Dimmer support for ESPHome + + Copyright © 2021 Anatoly Savchenkov + Copyright © 2020 Jeff Rescignano + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the “Software”), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom + the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or + substantial portions of the Software. + + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ----- + + If modifying this file, in addition to the license above, please ensure to include links back to the original code: + https://jeffresc.dev/blog/2020-10-10 + https://github.com/JeffResc/Sonoff-D1-Dimmer + https://github.com/arendst/Tasmota/blob/2d4a6a29ebc7153dbe2717e3615574ac1c84ba1d/tasmota/xdrv_37_sonoff_d1.ino#L119-L131 + + ----- +*/ + +/*********************************************************************************************\ + * Sonoff D1 dimmer 433 + * Mandatory/Optional + * ^ 0 1 2 3 4 5 6 7 8 9 A B C D E F 10 + * M AA 55 - Header + * M 01 04 - Version? + * M 00 0A - Following data length (10 bytes) + * O 01 - Power state (00 = off, 01 = on, FF = ignore) + * O 64 - Dimmer percentage (01 to 64 = 1 to 100%, 0 - ignore) + * O FF FF FF FF FF FF FF FF - Not used + * M 6C - CRC over bytes 2 to F (Addition) +\*********************************************************************************************/ +#include +#include "sonoff_d1.h" + +namespace esphome { +namespace sonoff_d1 { + +static const char *const TAG = "sonoff_d1"; + +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { + uint8_t crc = 0; + for (int i = 2; i < len - 1; i++) { + crc += cmd[i]; + } + return crc; +} + +void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { + // Update the checksum + cmd[len - 1] = this->calc_checksum_(cmd, len); +} + +void SonoffD1Output::skip_command_() { + size_t garbage = 0; + // Read out everything from the UART FIFO + while (this->available()) { + uint8_t value = this->read(); + ESP_LOGW(TAG, "[%04d] Skip %02d: 0x%02x from the dimmer", this->write_count_, garbage, value); + garbage++; + } + + // Warn about unexpected bytes in the protocol with UART dimmer + if (garbage) + ESP_LOGW(TAG, "[%04d] Skip %d bytes from the dimmer", this->write_count_, garbage); +} + +// This assumes some data is already available +bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { + // Do consistency check + if (cmd == nullptr || len < 7) { + ESP_LOGW(TAG, "[%04d] Too short command buffer (actual len is %d bytes, minimal is 7)", this->write_count_, len); + return false; + } + + // Read a minimal packet + if (this->read_array(cmd, 6)) { + ESP_LOGV(TAG, "[%04d] Reading from dimmer:", this->write_count_); + ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty(cmd, 6).c_str()); + + if (cmd[0] != 0xAA || cmd[1] != 0x55) { + ESP_LOGW(TAG, "[%04d] RX: wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); + this->skip_command_(); + return false; + } + if ((cmd[5] + 7 /*mandatory header + crc suffix length*/) > len) { + ESP_LOGW(TAG, "[%04d] RX: Payload length is unexpected (%d, max expected %d)", this->write_count_, cmd[5], + len - 7); + this->skip_command_(); + return false; + } + if (this->read_array(&cmd[6], cmd[5] + 1 /*checksum suffix*/)) { + ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty(&cmd[6], cmd[5] + 1).c_str()); + + // Check the checksum + uint8_t valid_checksum = this->calc_checksum_(cmd, cmd[5] + 7); + if (valid_checksum != cmd[cmd[5] + 7 - 1]) { + ESP_LOGW(TAG, "[%04d] RX: checksum mismatch (%d, expected %d)", this->write_count_, cmd[cmd[5] + 7 - 1], + valid_checksum); + this->skip_command_(); + return false; + } + len = cmd[5] + 7 /*mandatory header + suffix length*/; + + // Read remaining gardbled data (just in case, I don't see where this can appear now) + this->skip_command_(); + return true; + } + } else { + ESP_LOGW(TAG, "[%04d] RX: feedback timeout", this->write_count_); + this->skip_command_(); + } + return false; +} + +bool SonoffD1Output::read_ack_(const uint8_t *cmd, const size_t len) { + // Expected acknowledgement from rf chip + uint8_t ref_buffer[7] = {0xAA, 0x55, cmd[2], cmd[3], 0x00, 0x00, 0x00}; + uint8_t buffer[sizeof(ref_buffer)] = {0}; + uint32_t pos = 0, buf_len = sizeof(ref_buffer); + + // Update the reference checksum + this->populate_checksum_(ref_buffer, sizeof(ref_buffer)); + + // Read ack code, this either reads 7 bytes or exits with a timeout + this->read_command_(buffer, buf_len); + + // Compare response with expected response + while (pos < sizeof(ref_buffer) && ref_buffer[pos] == buffer[pos]) { + pos++; + } + if (pos == sizeof(ref_buffer)) { + ESP_LOGD(TAG, "[%04d] Acknowledge received", this->write_count_); + return true; + } else { + ESP_LOGW(TAG, "[%04d] Unexpected acknowledge received (possible clash of RF/HA commands), expected ack was:", + this->write_count_); + ESP_LOGW(TAG, "[%04d] %s", this->write_count_, format_hex_pretty(ref_buffer, sizeof(ref_buffer)).c_str()); + } + return false; +} + +bool SonoffD1Output::write_command_(uint8_t *cmd, const size_t len, bool needs_ack) { + // Do some consistency checks + if (len < 7) { + ESP_LOGW(TAG, "[%04d] Too short command (actual len is %d bytes, minimal is 7)", this->write_count_, len); + return false; + } + if (cmd[0] != 0xAA || cmd[1] != 0x55) { + ESP_LOGW(TAG, "[%04d] Wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); + return false; + } + if ((cmd[5] + 7 /*mandatory header + suffix length*/) != len) { + ESP_LOGW(TAG, "[%04d] Payload length field does not match packet lenght (%d, expected %d)", this->write_count_, + cmd[5], len - 7); + return false; + } + this->populate_checksum_(cmd, len); + + // Need retries here to handle the following cases: + // 1. On power up companion MCU starts to respond with a delay, so few first commands are ignored + // 2. UART command initiated by this component can clash with a command initiated by RF + uint32_t retries = 10; + do { + ESP_LOGV(TAG, "[%04d] Writing to the dimmer:", this->write_count_); + ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty(cmd, len).c_str()); + this->write_array(cmd, len); + this->write_count_++; + if (!needs_ack) + return true; + retries--; + } while (!this->read_ack_(cmd, len) && retries > 0); + + if (retries) { + return true; + } else { + ESP_LOGE(TAG, "[%04d] Unable to write to the dimmer", this->write_count_); + } + return false; +} + +bool SonoffD1Output::control_dimmer_(const bool binary, const uint8_t brightness) { + // Include our basic code from the Tasmota project, thank you again! + // 0 1 2 3 4 5 6 7 8 + uint8_t cmd[17] = {0xAA, 0x55, 0x01, 0x04, 0x00, 0x0A, 0x00, 0x00, 0xFF, + // 9 10 11 12 13 14 15 16 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00}; + + cmd[6] = binary; + cmd[7] = remap(brightness, 0, 100, this->min_value_, this->max_value_); + ESP_LOGI(TAG, "[%04d] Setting dimmer state to %s, raw brightness=%d", this->write_count_, ONOFF(binary), cmd[7]); + return this->write_command_(cmd, sizeof(cmd)); +} + +void SonoffD1Output::process_command_(const uint8_t *cmd, const size_t len) { + if (cmd[2] == 0x01 && cmd[3] == 0x04 && cmd[4] == 0x00 && cmd[5] == 0x0A) { + uint8_t ack_buffer[7] = {0xAA, 0x55, cmd[2], cmd[3], 0x00, 0x00, 0x00}; + // Ack a command from RF to ESP to prevent repeating commands + this->write_command_(ack_buffer, sizeof(ack_buffer), false); + ESP_LOGI(TAG, "[%04d] RF sets dimmer state to %s, raw brightness=%d", this->write_count_, ONOFF(cmd[6]), cmd[7]); + const uint8_t new_brightness = remap(cmd[7], this->min_value_, this->max_value_, 0, 100); + const bool new_state = cmd[6]; + + // Got light change state command. In all cases we revert the command immediately + // since we want to rely on ESP controlled transitions + if (new_state != this->last_binary_ || new_brightness != this->last_brightness_) { + this->control_dimmer_(this->last_binary_, this->last_brightness_); + } + + if (!this->use_rm433_remote_) { + // If RF remote is not used, this is a known ghost RF command + ESP_LOGI(TAG, "[%04d] Ghost command from RF detected, reverted", this->write_count_); + } else { + // If remote is used, initiate transition to the new state + this->publish_state_(new_state, new_brightness); + } + } else { + ESP_LOGW(TAG, "[%04d] Unexpected command received", this->write_count_); + } +} + +void SonoffD1Output::publish_state_(const bool is_on, const uint8_t brightness) { + if (light_state_) { + ESP_LOGV(TAG, "Publishing new state: %s, brightness=%d", ONOFF(is_on), brightness); + auto call = light_state_->make_call(); + call.set_state(is_on); + if (brightness != 0) { + // Brightness equal to 0 has a special meaning. + // D1 uses 0 as "previously set brightness". + // Usually zero brightness comes inside light ON command triggered by RF remote. + // Since we unconditionally override commands coming from RF remote in process_command_(), + // here we mimic the original behavior but with LightCall functionality + call.set_brightness((float) brightness / 100.0f); + } + call.perform(); + } +} + +// Set the device's traits +light::LightTraits SonoffD1Output::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; +} + +void SonoffD1Output::write_state(light::LightState *state) { + bool binary; + float brightness; + + // Fill our variables with the device's current state + state->current_values_as_binary(&binary); + state->current_values_as_brightness(&brightness); + + // Convert ESPHome's brightness (0-1) to the device's internal brightness (0-100) + const uint8_t calculated_brightness = std::round(brightness * 100); + + if (calculated_brightness == 0) { + // if(binary) ESP_LOGD(TAG, "current_values_as_binary() returns true for zero brightness"); + binary = false; + } + + // If a new value, write to the dimmer + if (binary != this->last_binary_ || calculated_brightness != this->last_brightness_) { + if (this->control_dimmer_(binary, calculated_brightness)) { + this->last_brightness_ = calculated_brightness; + this->last_binary_ = binary; + } else { + // Return to original value if failed to write to the dimmer + // TODO: Test me, can be tested if high-voltage part is not connected + ESP_LOGW(TAG, "Failed to update the dimmer, publishing the previous state"); + this->publish_state_(this->last_binary_, this->last_brightness_); + } + } +} + +void SonoffD1Output::dump_config() { + ESP_LOGCONFIG(TAG, "Sonoff D1 Dimmer: '%s'", this->light_state_ ? this->light_state_->get_name().c_str() : ""); + ESP_LOGCONFIG(TAG, " Use RM433 Remote: %s", ONOFF(this->use_rm433_remote_)); + ESP_LOGCONFIG(TAG, " Minimal brightness: %d", this->min_value_); + ESP_LOGCONFIG(TAG, " Maximal brightness: %d", this->max_value_); +} + +void SonoffD1Output::loop() { + // Read commands from the dimmer + // RF chip notifies ESP about remotely changed state with the same commands as we send + if (this->available()) { + ESP_LOGV(TAG, "Have some UART data in loop()"); + uint8_t buffer[17] = {0}; + size_t len = sizeof(buffer); + if (this->read_command_(buffer, len)) { + this->process_command_(buffer, len); + } + } +} + +} // namespace sonoff_d1 +} // namespace esphome diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h new file mode 100644 index 0000000000..4df0f5afb2 --- /dev/null +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -0,0 +1,85 @@ +#pragma once + +/* + sonoff_d1.h - Sonoff D1 Dimmer support for ESPHome + + Copyright © 2021 Anatoly Savchenkov + Copyright © 2020 Jeff Rescignano + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the “Software”), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom + the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or + substantial portions of the Software. + + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ----- + + If modifying this file, in addition to the license above, please ensure to include links back to the original code: + https://jeffresc.dev/blog/2020-10-10 + https://github.com/JeffResc/Sonoff-D1-Dimmer + https://github.com/arendst/Tasmota/blob/2d4a6a29ebc7153dbe2717e3615574ac1c84ba1d/tasmota/xdrv_37_sonoff_d1.ino#L119-L131 + + ----- +*/ + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/light/light_traits.h" + +namespace esphome { +namespace sonoff_d1 { + +class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { + public: + // LightOutput methods + light::LightTraits get_traits() override; + void setup_state(light::LightState *state) override { this->light_state_ = state; } + void write_state(light::LightState *state) override; + + // Component methods + void setup() override{}; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return esphome::setup_priority::DATA; } + + // Custom methods + void set_use_rm433_remote(const bool use_rm433_remote) { this->use_rm433_remote_ = use_rm433_remote; } + void set_min_value(const uint8_t min_value) { this->min_value_ = min_value; } + void set_max_value(const uint8_t max_value) { this->max_value_ = max_value; } + + protected: + uint8_t min_value_{0}; + uint8_t max_value_{100}; + bool use_rm433_remote_{false}; + bool last_binary_{false}; + uint8_t last_brightness_{0}; + int write_count_{0}; + int read_count_{0}; + light::LightState *light_state_{nullptr}; + + uint8_t calc_checksum_(const uint8_t *cmd, size_t len); + void populate_checksum_(uint8_t *cmd, size_t len); + void skip_command_(); + bool read_command_(uint8_t *cmd, size_t &len); + bool read_ack_(const uint8_t *cmd, size_t len); + bool write_command_(uint8_t *cmd, size_t len, bool needs_ack = true); + bool control_dimmer_(bool binary, uint8_t brightness); + void process_command_(const uint8_t *cmd, size_t len); + void publish_state_(bool is_on, uint8_t brightness); +}; + +} // namespace sonoff_d1 +} // namespace esphome diff --git a/tests/test3.yaml b/tests/test3.yaml index a6ad6b9e92..58cb14740f 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -1229,6 +1229,12 @@ light: name: Icicle Lights pin_a: out pin_b: out2 + - platform: sonoff_d1 + uart_id: uart2 + use_rm433_remote: False + name: Sonoff D1 Dimmer + id: d1_light + restore_mode: RESTORE_DEFAULT_OFF servo: id: my_servo