diff --git a/esphome/components/ac_dimmer/__init__.py b/esphome/components/ac_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp new file mode 100644 index 0000000000..a60cc9e29a --- /dev/null +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -0,0 +1,217 @@ +#include "ac_dimmer.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef ARDUINO_ARCH_ESP8266 +#include +#endif + +namespace esphome { +namespace ac_dimmer { + +static const char *TAG = "ac_dimmer"; + +// Global array to store dimmer objects +static AcDimmerDataStore *all_dimmers[32]; + +/// Time in microseconds the gate should be held high +/// 10µs should be long enough for most triacs +/// For reference: BT136 datasheet says 2µs nominal (page 7) +static uint32_t GATE_ENABLE_TIME = 10; + +/// Function called from timer interrupt +/// Input is current time in microseconds (micros()) +/// Returns when next "event" is expected in µs, or 0 if no such event known. +uint32_t ICACHE_RAM_ATTR HOT AcDimmerDataStore::timer_intr(uint32_t now) { + // If no ZC signal received yet. + if (this->crossed_zero_at == 0) + return 0; + + uint32_t time_since_zc = now - this->crossed_zero_at; + if (this->value == 65535 || this->value == 0) { + return 0; + } + + if (this->enable_time_us != 0 && time_since_zc >= this->enable_time_us) { + this->enable_time_us = 0; + this->gate_pin->digital_write(true); + // Prevent too short pulses + this->disable_time_us = max(this->disable_time_us, time_since_zc + GATE_ENABLE_TIME); + } + if (this->disable_time_us != 0 && time_since_zc >= this->disable_time_us) { + this->disable_time_us = 0; + this->gate_pin->digital_write(false); + } + + if (time_since_zc < this->enable_time_us) + // Next event is enable, return time until that event + return this->enable_time_us - time_since_zc; + else if (time_since_zc < disable_time_us) { + // Next event is disable, return time until that event + return this->disable_time_us - time_since_zc; + } + + if (time_since_zc >= this->cycle_time_us) { + // Already past last cycle time, schedule next call shortly + return 100; + } + + return this->cycle_time_us - time_since_zc; +} + +/// Run timer interrupt code and return in how many µs the next event is expected +uint32_t ICACHE_RAM_ATTR HOT timer_interrupt() { + // run at least with 1kHz + uint32_t min_dt_us = 1000; + uint32_t now = micros(); + for (auto *dimmer : all_dimmers) { + if (dimmer == nullptr) + // no more dimmers + break; + uint32_t res = dimmer->timer_intr(now); + if (res != 0 && res < min_dt_us) + min_dt_us = res; + } + // return time until next timer1 interrupt in µs + return min_dt_us; +} + +/// GPIO interrupt routine, called when ZC pin triggers +void ICACHE_RAM_ATTR HOT AcDimmerDataStore::gpio_intr() { + uint32_t prev_crossed = this->crossed_zero_at; + + // 50Hz mains frequency should give a half cycle of 10ms a 60Hz will give 8.33ms + // in any case the cycle last at least 5ms + this->crossed_zero_at = micros(); + uint32_t cycle_time = this->crossed_zero_at - prev_crossed; + if (cycle_time > 5000) { + this->cycle_time_us = cycle_time; + } else { + // Otherwise this is noise and this is 2nd (or 3rd...) fall in the same pulse + // Consider this is the right fall edge and accumulate the cycle time instead + this->cycle_time_us += cycle_time; + } + + if (this->value == 65535) { + // fully on, enable output immediately + this->gate_pin->digital_write(true); + } else if (this->init_cycle) { + // send a full cycle + this->init_cycle = false; + this->enable_time_us = 0; + this->disable_time_us = cycle_time_us; + } else if (this->value == 0) { + // fully off, disable output immediately + this->gate_pin->digital_write(false); + } else { + if (this->method == DIM_METHOD_TRAILING) { + this->enable_time_us = 1; // cannot be 0 + this->disable_time_us = max((uint32_t) 10, this->value * this->cycle_time_us / 65535); + } else { + // calculate time until enable in µs: (1.0-value)*cycle_time, but with integer arithmetic + // also take into account min_power + auto min_us = this->cycle_time_us * this->min_power / 1000; + this->enable_time_us = max((uint32_t) 1, ((65535 - this->value) * (this->cycle_time_us - min_us)) / 65535); + if (this->method == DIM_METHOD_LEADING_PULSE) { + // Minimum pulse time should be enough for the triac to trigger when it is close to the ZC zone + // this is for brightness near 99% + this->disable_time_us = max(this->enable_time_us + GATE_ENABLE_TIME, (uint32_t) cycle_time_us / 10); + } else { + this->gate_pin->digital_write(false); + this->disable_time_us = this->cycle_time_us; + } + } + } +} + +void ICACHE_RAM_ATTR HOT AcDimmerDataStore::s_gpio_intr(AcDimmerDataStore *store) { + // Attaching pin interrupts on the same pin will override the previous interupt + // However, the user expects that multiple dimmers sharing the same ZC pin will work. + // We solve this in a bit of a hacky way: On each pin interrupt, we check all dimmers + // if any of them are using the same ZC pin, and also trigger the interrupt for *them*. + for (auto *dimmer : all_dimmers) { + if (dimmer == nullptr) + break; + if (dimmer->zero_cross_pin_number == store->zero_cross_pin_number) { + dimmer->gpio_intr(); + } + } +} + +#ifdef ARDUINO_ARCH_ESP32 +// ESP32 implementation, uses basically the same code but needs to wrap +// timer_interrupt() function to auto-reschedule +static hw_timer_t *dimmer_timer = nullptr; +void ICACHE_RAM_ATTR HOT AcDimmerDataStore::s_timer_intr() { timer_interrupt(); } +#endif + +void AcDimmer::setup() { + // extend all_dimmers array with our dimmer + + // Need to be sure the zero cross pin is setup only once, ESP8266 fails and ESP32 seems to fail silently + auto setup_zero_cross_pin = true; + + for (auto &all_dimmer : all_dimmers) { + if (all_dimmer == nullptr) { + all_dimmer = &this->store_; + break; + } + if (all_dimmer->zero_cross_pin_number == this->zero_cross_pin_->get_pin()) { + setup_zero_cross_pin = false; + } + } + + this->gate_pin_->setup(); + this->store_.gate_pin = this->gate_pin_->to_isr(); + this->store_.zero_cross_pin_number = this->zero_cross_pin_->get_pin(); + this->store_.min_power = static_cast(this->min_power_ * 1000); + this->min_power_ = 0; + this->store_.method = this->method_; + + if (setup_zero_cross_pin) { + this->zero_cross_pin_->setup(); + this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr(); + this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_, FALLING); + } + +#ifdef ARDUINO_ARCH_ESP8266 + // Uses ESP8266 waveform (soft PWM) class + // PWM and AcDimmer can even run at the same time this way + setTimer1Callback(&timer_interrupt); +#endif +#ifdef ARDUINO_ARCH_ESP32 + // 80 Divider -> 1 count=1µs + dimmer_timer = timerBegin(0, 80, true); + timerAttachInterrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr, true); + // For ESP32, we can't use dynamic interval calculation because the timerX functions + // are not callable from ISR (placed in flash storage). + // Here we just use an interrupt firing every 50 µs. + timerAlarmWrite(dimmer_timer, 50, true); + timerAlarmEnable(dimmer_timer); +#endif +} +void AcDimmer::write_state(float state) { + auto new_value = static_cast(roundf(state * 65535)); + if (new_value != 0 && this->store_.value == 0) + this->store_.init_cycle = this->init_with_half_cycle_; + this->store_.value = new_value; +} +void AcDimmer::dump_config() { + ESP_LOGCONFIG(TAG, "AcDimmer:"); + LOG_PIN(" Output Pin: ", this->gate_pin_); + LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_); + ESP_LOGCONFIG(TAG, " Min Power: %.1f%%", this->store_.min_power / 10.0f); + ESP_LOGCONFIG(TAG, " Init with half cycle: %s", YESNO(this->init_with_half_cycle_)); + if (method_ == DIM_METHOD_LEADING_PULSE) + ESP_LOGCONFIG(TAG, " Method: leading pulse"); + else if (method_ == DIM_METHOD_LEADING) + ESP_LOGCONFIG(TAG, " Method: leading"); + else + ESP_LOGCONFIG(TAG, " Method: trailing"); + + LOG_FLOAT_OUTPUT(this); + ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2); +} + +} // namespace ac_dimmer +} // namespace esphome diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h new file mode 100644 index 0000000000..00da061cfd --- /dev/null +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/esphal.h" +#include "esphome/components/output/float_output.h" + +namespace esphome { +namespace ac_dimmer { + +enum DimMethod { DIM_METHOD_LEADING_PULSE = 0, DIM_METHOD_LEADING, DIM_METHOD_TRAILING }; + +struct AcDimmerDataStore { + /// Zero-cross pin + ISRInternalGPIOPin *zero_cross_pin; + /// Zero-cross pin number - used to share ZC pin across multiple dimmers + uint8_t zero_cross_pin_number; + /// Output pin to write to + ISRInternalGPIOPin *gate_pin; + /// Value of the dimmer - 0 to 65535. + uint16_t value; + /// Minimum power for activation + uint16_t min_power; + /// Time between the last two ZC pulses + uint32_t cycle_time_us; + /// Time (in micros()) of last ZC signal + uint32_t crossed_zero_at; + /// Time since last ZC pulse to enable gate pin. 0 means not set. + uint32_t enable_time_us; + /// Time since last ZC pulse to disable gate pin. 0 means no disable. + uint32_t disable_time_us; + /// Set to send the first half ac cycle complete + bool init_cycle; + /// Dimmer method + DimMethod method; + + uint32_t timer_intr(uint32_t now); + + void gpio_intr(); + static void s_gpio_intr(AcDimmerDataStore *store); +#ifdef ARDUINO_ARCH_ESP32 + static void s_timer_intr(); +#endif +}; + +class AcDimmer : public output::FloatOutput, public Component { + public: + void setup() override; + + void dump_config() override; + void set_gate_pin(GPIOPin *gate_pin) { gate_pin_ = gate_pin; } + void set_zero_cross_pin(GPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; } + void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; } + void set_method(DimMethod method) { method_ = method; } + + protected: + void write_state(float state) override; + + GPIOPin *gate_pin_; + GPIOPin *zero_cross_pin_; + AcDimmerDataStore store_; + bool init_with_half_cycle_; + DimMethod method_; +}; + +} // namespace ac_dimmer +} // namespace esphome diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py new file mode 100644 index 0000000000..16f04ac984 --- /dev/null +++ b/esphome/components/ac_dimmer/output.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components import output +from esphome.const import CONF_ID, CONF_MIN_POWER, CONF_METHOD + +ac_dimmer_ns = cg.esphome_ns.namespace('ac_dimmer') +AcDimmer = ac_dimmer_ns.class_('AcDimmer', output.FloatOutput, cg.Component) + +DimMethod = ac_dimmer_ns.enum('DimMethod') +DIM_METHODS = { + 'LEADING_PULSE': DimMethod.DIM_METHOD_LEADING_PULSE, + 'LEADING': DimMethod.DIM_METHOD_LEADING, + 'TRAILING': DimMethod.DIM_METHOD_TRAILING, +} + +CONF_GATE_PIN = 'gate_pin' +CONF_ZERO_CROSS_PIN = 'zero_cross_pin' +CONF_INIT_WITH_HALF_CYCLE = 'init_with_half_cycle' +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend({ + cv.Required(CONF_ID): cv.declare_id(AcDimmer), + cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema, + cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean, + cv.Optional(CONF_METHOD, default='leading pulse'): cv.enum(DIM_METHODS, upper=True, space='_'), +}).extend(cv.COMPONENT_SCHEMA) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + + # override default min power to 10% + if CONF_MIN_POWER not in config: + config[CONF_MIN_POWER] = 0.1 + yield output.register_output(var, config) + + pin = yield cg.gpio_pin_expression(config[CONF_GATE_PIN]) + cg.add(var.set_gate_pin(pin)) + pin = yield cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) + cg.add(var.set_zero_cross_pin(pin)) + cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) + cg.add(var.set_method(config[CONF_METHOD])) diff --git a/tests/test1.yaml b/tests/test1.yaml index 8483281b59..16fc382b2f 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -1012,6 +1012,10 @@ output: id: id24 pin: GPIO26 period: 15s + - platform: ac_dimmer + id: dimmer1 + gate_pin: GPIO5 + zero_cross_pin: GPIO26 light: - platform: binary diff --git a/tests/test3.yaml b/tests/test3.yaml index e7c3da18de..2a37095aca 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -649,6 +649,10 @@ output: return {s}; outputs: - id: custom_float + - platform: ac_dimmer + id: dimmer1 + gate_pin: GPIO5 + zero_cross_pin: GPIO12 mcp23017: id: mcp23017_hub