diff --git a/CODEOWNERS b/CODEOWNERS index 8fbbacef59..f8d90cdb22 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -305,6 +305,7 @@ esphome/components/online_image/* @guillempages esphome/components/opentherm/* @olegtarasov esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core +esphome/components/oxt_dimmer/* @michau-krakow esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @clydebarrow @hwstar esphome/components/pcf85063/* @brogon diff --git a/esphome/components/oxt_dimmer/__init__.py b/esphome/components/oxt_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/oxt_dimmer/light.py b/esphome/components/oxt_dimmer/light.py new file mode 100644 index 0000000000..5151d9e508 --- /dev/null +++ b/esphome/components/oxt_dimmer/light.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components import light, uart +from esphome.const import ( + CONF_CHANNELS, + CONF_GAMMA_CORRECT, + CONF_OUTPUT_ID, + CONF_MIN_VALUE, + CONF_MAX_VALUE, + CONF_SENSING_PIN, +) + + +CODEOWNERS = ["@michau-krakow"] +DEPENDENCIES = ["uart", "light"] + +oxt_dimmer_ns = cg.esphome_ns.namespace("oxt_dimmer") +OxtController = oxt_dimmer_ns.class_("OxtController", cg.Component, uart.UARTDevice) +OxtDimmerChannel = oxt_dimmer_ns.class_( + "OxtDimmerChannel", cg.Component, light.LightOutput +) + +CHANNEL_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(OxtDimmerChannel), + cv.Optional(CONF_MIN_VALUE, default=50): cv.int_range(min=0, max=255), + cv.Optional(CONF_MAX_VALUE, default=255): cv.int_range(min=0, max=255), + cv.Optional(CONF_SENSING_PIN): pins.gpio_input_pin_schema, + # override defaults + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + } +) + +CONFIG_SCHEMA = ( + # allow at least 15ms for UART frame of 10-12 bytes @9600bps to be fully transmitted! + cv.polling_component_schema("50 ms") + .extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(OxtController), + cv.Required(CONF_CHANNELS): cv.All( + cv.ensure_list(CHANNEL_SCHEMA), cv.Length(min=1, max=2) + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "oxt_dimmer", baud_rate=9600, require_tx=True, require_rx=False +) + + +async def to_code(config): + ctrl = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await cg.register_component(ctrl, config) + await uart.register_uart_device(ctrl, config) + + for index, channel_cfg in enumerate(config[CONF_CHANNELS]): + channel = cg.new_Pvariable(channel_cfg[CONF_OUTPUT_ID]) + await cg.register_component(channel, channel_cfg) + await light.register_light(channel, channel_cfg) + cg.add(channel.set_min_value(channel_cfg[CONF_MIN_VALUE])) + cg.add(channel.set_max_value(channel_cfg[CONF_MAX_VALUE])) + if CONF_SENSING_PIN in channel_cfg: + sensing_pin = await cg.gpio_pin_expression(channel_cfg[CONF_SENSING_PIN]) + cg.add(channel.set_sensing_pin(sensing_pin)) + cg.add(ctrl.add_channel(index, channel)) diff --git a/esphome/components/oxt_dimmer/oxt_dimmer.cpp b/esphome/components/oxt_dimmer/oxt_dimmer.cpp new file mode 100644 index 0000000000..df9e4b3a60 --- /dev/null +++ b/esphome/components/oxt_dimmer/oxt_dimmer.cpp @@ -0,0 +1,173 @@ +/* + Copyright © 2023 +*/ + +/*********************************************************************************************\ + * The OXT dimmer uses simple protocol over serial @9600bps where each frame looks like: + * 0 1 2 3 4 5 6 7 8 9 A B + * 00 00 ff 55 - Header + * 01 - Channel being updated + * 4b - Light brightness of channel 1 [00..ff] + * 4c - Light brightness of channel 2 [00..ff] + * 05 dc 0a 00 00 - Footer +\*********************************************************************************************/ +#include "oxt_dimmer.h" + +namespace esphome { +namespace oxt_dimmer { + +static const char *const TAG = "oxt"; + +void OxtController::update() { + for (const auto &channel : channels_) { + if (channel) + channel->update_sensing_input(); + } +} + +void OxtController::send_to_mcu_(const OxtDimmerChannel *updated_channel) { + struct { + uint8_t header[3]; + uint8_t update; + uint8_t channel[2]; + uint8_t footer[4]; + } frame{{0x00, 0xff, 0x55}, 0x00, {0x00, 0x00}, {0x05, 0xdc, 0x0a, 0x00}}; + + for (size_t index = 0; index < MAX_CHANNELS; index++) { + auto *channel = channels_[index]; + if (channel == nullptr) + continue; + + auto binary = channel->is_on(); + auto brightness = channel->brightness(); + if (binary) { + frame.channel[index] = brightness; + } + + if (channel == updated_channel) { + frame.update = index + 1; + ESP_LOGI(TAG, "Setting channel %u state=%s, raw brightness=%d", index, ONOFF(binary), brightness); + } + } + + if (frame.update == 0) { + ESP_LOGE(TAG, "Unable to find channel index"); + return; + } + + ESP_LOGV(TAG, "Frame: %s", format_hex_pretty(reinterpret_cast(&frame), sizeof(frame)).c_str()); + this->write_array(reinterpret_cast(&frame), sizeof(frame)); +} + +light::LightTraits OxtDimmerChannel::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; +} + +void OxtDimmerChannel::write_state(light::LightState *state) { + if (controller_ == nullptr) { + ESP_LOGE(TAG, "No controller - state change ignored"); + return; + } + + 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 internal brightness (0-255) + const uint8_t calculated_brightness = remap(brightness, 0.0, 1.0f, min_value_, max_value_); + + if (calculated_brightness == 0) { + binary = false; + } + + // If a new value, write to the dimmer + if (binary != binary_ || calculated_brightness != brightness_) { + brightness_ = calculated_brightness; + binary_ = binary; + controller_->send_to_mcu_(this); + } +} + +void OxtDimmerChannel::short_press_() { + ESP_LOGI(TAG, "short_press"); + light_state_->toggle().perform(); +} + +void OxtDimmerChannel::periodic_long_press_() { + // Note: This function is operating on ESPHome brightness values in range 0-1 float + float brightness; + light_state_->current_values_as_brightness(&brightness); + + ESP_LOGD(TAG, "brightness: %0.2f, direction: %d, millis %u", brightness, sensing_state_.direction_, millis()); + brightness = clamp(brightness + sensing_state_.direction_ * 0.02f, 0.0f, 1.0f); + ESP_LOGI(TAG, "next brightness: %0.2f", brightness); + + light_state_->make_call() + .set_brightness(brightness) + .set_state((brightness > 0)) + .set_transition_length({}) // cancel transition, if any + .perform(); +} + +void OxtDimmerChannel::update_sensing_input() { + if (!sensing_state_.sensing_pin_) + return; + + bool btn_pressed = sensing_state_.sensing_pin_->digital_read(); + + switch (sensing_state_.state_) { + case SensingStateT::STATE_RELEASED: + if (btn_pressed) { + sensing_state_.millis_pressed_ = millis(); + sensing_state_.state_ = SensingStateT::STATE_DEBOUNCING; + } + break; + + case SensingStateT::STATE_DEBOUNCING: + if (!btn_pressed) { + sensing_state_.millis_pressed_ = 0; + sensing_state_.state_ = SensingStateT::STATE_RELEASED; + } else if (millis() - sensing_state_.millis_pressed_ > 50) { + sensing_state_.state_ = SensingStateT::STATE_PRESSED; + } + break; + + case SensingStateT::STATE_PRESSED: + if (!btn_pressed) { + short_press_(); + sensing_state_.state_ = SensingStateT::STATE_RELEASED; + } else if (millis() - sensing_state_.millis_pressed_ > 1000) { + sensing_state_.state_ = SensingStateT::STATE_LONGPRESS; + sensing_state_.direction_ *= -1; + } + break; + + case SensingStateT::STATE_LONGPRESS: + if (btn_pressed) { + periodic_long_press_(); + } else + sensing_state_.state_ = SensingStateT::STATE_RELEASED; + break; + + default: + ESP_LOGE(TAG, "should never get here"); + } +} + +void OxtDimmerChannel::dump_config() { + ESP_LOGCONFIG(TAG, "OXT channel: '%s'", light_state_ ? light_state_->get_name().c_str() : ""); + ESP_LOGCONFIG(TAG, " Minimal brightness: %d", min_value_); + ESP_LOGCONFIG(TAG, " Maximal brightness: %d", max_value_); + ESP_LOGCONFIG(TAG, " Sensing pin: %s", + sensing_state_.sensing_pin_ ? sensing_state_.sensing_pin_->dump_summary().c_str() : "none"); +} + +void OxtController::dump_config() { ESP_LOGCONFIG(TAG, "Oxt dimmer"); } + +} // namespace oxt_dimmer +} // namespace esphome diff --git a/esphome/components/oxt_dimmer/oxt_dimmer.h b/esphome/components/oxt_dimmer/oxt_dimmer.h new file mode 100644 index 0000000000..15369ce8e5 --- /dev/null +++ b/esphome/components/oxt_dimmer/oxt_dimmer.h @@ -0,0 +1,97 @@ +#pragma once + +/* + Copyright © 2023 +*/ + +#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 oxt_dimmer { + +class OxtController; + +/** + * OxtDimmerChannel inherits from light::LightOutput and provides "light" + * functionality towards front-end, ESPHome, HASS... + */ +class OxtDimmerChannel : public light::LightOutput, public Component { + public: + // Component overrides + void dump_config() override; + + // LightOutput overrides + light::LightTraits get_traits() override; + void setup_state(light::LightState *state) override { light_state_ = state; } + void write_state(light::LightState *state) override; + + // Own methods + bool is_on() { return binary_; } + uint8_t brightness() { return brightness_; } + + void set_min_value(const uint8_t min_value) { min_value_ = min_value; } + void set_max_value(const uint8_t max_value) { max_value_ = max_value; } + void set_sensing_pin(GPIOPin *sensing_pin) { sensing_state_.sensing_pin_ = sensing_pin; } + void set_controller(OxtController *control) { controller_ = control; } + + void update_sensing_input(); + + protected: + OxtController *controller_{nullptr}; + + struct SensingStateT { + enum { STATE_RELEASED, STATE_DEBOUNCING, STATE_PRESSED, STATE_LONGPRESS } state_ = STATE_RELEASED; + uint32_t millis_pressed_ = 0; + int direction_ = 1; + GPIOPin *sensing_pin_; + } sensing_state_; + + // light implementation + uint8_t min_value_{50}; + uint8_t max_value_{255}; + bool binary_{false}; + uint8_t brightness_{0}; + light::LightState *light_state_{nullptr}; + + void short_press_(); + void periodic_long_press_(); +}; + +/** + * OxtController class takes care of communication with dimming MCU (back-end) + * and polling external switch(es) using GPIO input pins + */ +class OxtController : public uart::UARTDevice, public PollingComponent { + friend class OxtDimmerChannel; + + public: + static constexpr size_t MAX_CHANNELS = 2; + + // Component methods + void dump_config() override; + float get_setup_priority() const override { return esphome::setup_priority::DATA; } + + // PollingComponent methods + void update() override; + + // Own methods + void add_channel(uint8_t index, OxtDimmerChannel *channel) { + channels_[index] = channel; + channel->set_controller(this); + } + + protected: + void send_to_mcu_(const OxtDimmerChannel *channel); + + private: + OxtDimmerChannel *channels_[MAX_CHANNELS]{nullptr}; +}; + +} // namespace oxt_dimmer +} // namespace esphome diff --git a/tests/test12.yaml b/tests/test12.yaml new file mode 100644 index 0000000000..c4d03981f7 --- /dev/null +++ b/tests/test12.yaml @@ -0,0 +1,42 @@ +# Tests for OXT dimmer using bk7xx board +--- +esphome: + name: bk72xx-with-oxt-dimmer-test + +bk72xx: + board: cb3s + +wifi: + ssid: "ssid" + +ota: + +captive_portal: + +# Disable UART logging - we need UART to talk to dimming MCU +logger: + baud_rate: 0 + level: VERBOSE + +uart: + tx_pin: GPIO11 + baud_rate: 9600 + +light: + - platform: oxt_dimmer + channels: + - name: lamp1 + id: lamp1 + min_value: 0 + max_value: 255 + sensing_pin: + number: GPIO08 + inverted: true + mode: input_pullup + - name: lamp2 + id: lamp2 + sensing_pin: + number: GPIO09 + inverted: true + mode: input_pullup + default_transition_length: 1s