diff --git a/CODEOWNERS b/CODEOWNERS index 6dbdef12ec55..238b37a6e3de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dht/* @OttoWinter esphome/components/ds1307/* @badbadc0ffee esphome/components/dsmr/* @glmnet @zuidwijk +esphome/components/emporia_vue/* @Maelstrom96 @flaviut @krconv esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @jesserockz esphome/components/esp32_ble_server/* @jesserockz diff --git a/esphome/components/emporia_vue/__init__.py b/esphome/components/emporia_vue/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/esphome/components/emporia_vue/emporia_vue.cpp b/esphome/components/emporia_vue/emporia_vue.cpp new file mode 100644 index 000000000000..149523168d50 --- /dev/null +++ b/esphome/components/emporia_vue/emporia_vue.cpp @@ -0,0 +1,159 @@ +#include "emporia_vue.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace emporia_vue { + +static const char *const TAG = "emporia_vue"; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +EmporiaVueComponent *global_emporia_vue_component = nullptr; + +void EmporiaVueComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Emporia Vue"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Sensor Poll Interval: %dms", this->sensor_poll_interval_); + + for (auto *phase : this->phases_) { + std::string wire; + switch (phase->get_input_wire()) { + case PhaseInputWire::BLACK: + wire = "BLACK"; + break; + case PhaseInputWire::RED: + wire = "RED"; + break; + case PhaseInputWire::BLUE: + wire = "BLUE"; + break; + } + ESP_LOGCONFIG(TAG, " Phase Config"); + ESP_LOGCONFIG(TAG, " Wire: %s", wire.c_str()); + ESP_LOGCONFIG(TAG, " Calibration: %f", phase->get_calibration()); + LOG_SENSOR(" ", "Voltage", phase->get_voltage_sensor()); + } + + for (auto *ct_sensor : this->ct_sensors_) { + LOG_SENSOR(" ", "CT", ct_sensor); + ESP_LOGCONFIG(TAG, " Phase Calibration: %f", ct_sensor->get_phase()->get_calibration()); + ESP_LOGCONFIG(TAG, " CT Port Index: %d", ct_sensor->get_input_port()); + } +} + +void EmporiaVueComponent::setup() { +#ifdef USING_OTA_COMPONENT + // OTA callback to prevent the i2c task to crash + if (this->ota_) { + ESP_LOGV(TAG, "Adding OTA state callback"); + this->ota_->add_on_state_callback([this](ota::OTAState state, float var, uint8_t error_code) { + eTaskState i2c_request_task_status = eTaskGetState(this->i2c_request_task_); + + if (state == ota::OTAState::OTA_STARTED && + (i2c_request_task_status == eRunning || i2c_request_task_status == eReady || + i2c_request_task_status == eBlocked)) { + ESP_LOGV(TAG, "OTA Update started - Suspending i2c_request_task_"); + vTaskSuspend(this->i2c_request_task_); + } + if (state == ota::OTAState::OTA_ERROR && i2c_request_task_status == eSuspended) { + ESP_LOGV(TAG, "OTA Update failed - Resuming i2c_request_task_"); + vTaskResume(this->i2c_request_task_); + } + }); + } +#endif + + global_emporia_vue_component = this; + + this->i2c_data_queue_ = xQueueCreate(1, sizeof(SensorReading)); + xTaskCreatePinnedToCore(&EmporiaVueComponent::i2c_request_task, "i2c_request_task", 4096, nullptr, 0, + &this->i2c_request_task_, 0); +} + +void EmporiaVueComponent::i2c_request_task(void *pv) { + const TickType_t poll_interval = global_emporia_vue_component->get_sensor_poll_interval() / portTICK_PERIOD_MS; + TickType_t last_poll; + uint8_t last_sequence_num = 0; + + while (true) { + last_poll = xTaskGetTickCount(); + SensorReading sensor_reading; + + i2c::ErrorCode err = + global_emporia_vue_component->read(reinterpret_cast(&sensor_reading), sizeof(sensor_reading)); + + if (err != i2c::ErrorCode::ERROR_OK) { + ESP_LOGE(TAG, "Failed to read from sensor due to I2C error %d", err); + } else if (sensor_reading.end != 0) { + ESP_LOGE(TAG, "Failed to read from sensor due to a malformed reading, should end in null bytes but is %d", + sensor_reading.end); + } else if (!sensor_reading.is_unread) { + ESP_LOGV(TAG, "Ignoring sensor reading that is marked as read"); + } else { + if (last_sequence_num && sensor_reading.sequence_num > last_sequence_num + 1) { + ESP_LOGW(TAG, "Detected %d missing reading(s), data may not be accurate!", + sensor_reading.sequence_num - last_sequence_num - 1); + } + + xQueueOverwrite(global_emporia_vue_component->i2c_data_queue_, &sensor_reading); + ESP_LOGV(TAG, "Added sensor reading with sequence number %d to queue", sensor_reading.sequence_num); + + last_sequence_num = sensor_reading.sequence_num; + } + vTaskDelayUntil(&last_poll, poll_interval); + } +} + +void EmporiaVueComponent::loop() { + SensorReading sensor_reading; + + if (xQueueReceive(this->i2c_data_queue_, &sensor_reading, 0) == pdTRUE) { + ESP_LOGV(TAG, "Received sensor reading with sequence number %d from queue", sensor_reading.sequence_num); + for (PhaseConfig *phase : this->phases_) { + phase->update_from_reading(sensor_reading); + } + + for (CTSensor *ct_sensor : this->ct_sensors_) { + ct_sensor->update_from_reading(sensor_reading); + } + } +} + +void PhaseConfig::update_from_reading(const SensorReading &sensor_reading) { + if (this->voltage_sensor_) { + float calibrated_voltage = sensor_reading.voltage[this->input_wire_] * this->calibration_; + this->voltage_sensor_->publish_state(calibrated_voltage); + } +} + +int32_t PhaseConfig::extract_power_for_phase(const ReadingPowerEntry &power_entry) { + switch (this->input_wire_) { + case PhaseInputWire::BLACK: + return power_entry.phase_black; + case PhaseInputWire::RED: + return power_entry.phase_red; + case PhaseInputWire::BLUE: + return power_entry.phase_blue; + default: + ESP_LOGE(TAG, "Unsupported phase input wire, this should never happen"); + return -1; + } +} + +void CTSensor::update_from_reading(const SensorReading &sensor_reading) { + ReadingPowerEntry power_entry = sensor_reading.power[this->input_port_]; + int32_t raw_power = this->phase_->extract_power_for_phase(power_entry); + float calibrated_power = this->get_calibrated_power(raw_power); + this->publish_state(calibrated_power); +} + +float CTSensor::get_calibrated_power(int32_t raw_power) const { + float calibration = this->phase_->get_calibration(); + + float correction_factor = (this->input_port_ < 3) ? 5.5 : 22; + + return (raw_power * calibration) / correction_factor; +} + +} // namespace emporia_vue +} // namespace esphome diff --git a/esphome/components/emporia_vue/emporia_vue.h b/esphome/components/emporia_vue/emporia_vue.h new file mode 100644 index 000000000000..c6ec1a1bc6c6 --- /dev/null +++ b/esphome/components/emporia_vue/emporia_vue.h @@ -0,0 +1,142 @@ +#pragma once + +#ifdef USE_ESP32 + +#include + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#ifdef USING_OTA_COMPONENT +#include "esphome/components/ota/ota_component.h" +#endif + +#include +#include +#include + +namespace esphome { +namespace emporia_vue { + +struct __attribute__((__packed__)) ReadingPowerEntry { + int32_t phase_black; + int32_t phase_red; + int32_t phase_blue; +}; + +struct __attribute__((__packed__)) SensorReading { + bool is_unread; + uint8_t checksum; + uint8_t unknown; + uint8_t sequence_num; + + ReadingPowerEntry power[19]; + + uint16_t voltage[3]; + uint16_t frequency; + uint16_t degrees[2]; + + uint16_t current[19]; + + uint16_t end; +}; + +class PhaseConfig; +class CTSensor; + +class EmporiaVueComponent : public Component, public i2c::I2CDevice { + public: + void dump_config() override; + + void set_sensor_poll_interval(uint32_t sensor_poll_interval) { this->sensor_poll_interval_ = sensor_poll_interval; } + uint32_t get_sensor_poll_interval() const { return this->sensor_poll_interval_; } + void set_phases(std::vector phases) { this->phases_ = std::move(phases); } + void set_ct_sensors(std::vector sensors) { this->ct_sensors_ = std::move(sensors); } +#ifdef USING_OTA_COMPONENT + void set_ota(ota::OTAComponent *ota) { this->ota_ = ota; } +#endif + + void setup() override; + void loop() override; + + protected: + static void i2c_request_task(void *pv); + + uint32_t sensor_poll_interval_; + std::vector phases_; + std::vector ct_sensors_; + QueueHandle_t i2c_data_queue_; +#ifdef USING_OTA_COMPONENT + ota::OTAComponent *ota_{nullptr}; +#endif + TaskHandle_t i2c_request_task_; +}; + +enum PhaseInputWire : uint8_t { + BLACK = 0, + RED = 1, + BLUE = 2, +}; + +class PhaseConfig { + public: + void set_input_wire(PhaseInputWire input_wire) { this->input_wire_ = input_wire; } + PhaseInputWire get_input_wire() const { return this->input_wire_; } + void set_calibration(float calibration) { this->calibration_ = calibration; } + float get_calibration() const { return this->calibration_; } + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } + sensor::Sensor *get_voltage_sensor() const { return this->voltage_sensor_; } + + void update_from_reading(const SensorReading &sensor_reading); + + int32_t extract_power_for_phase(const ReadingPowerEntry &power_entry); + + protected: + PhaseInputWire input_wire_; + float calibration_; + sensor::Sensor *voltage_sensor_{nullptr}; +}; + +enum CTInputPort : uint8_t { + A = 0, + B = 1, + C = 2, + ONE = 3, + TWO = 4, + THREE = 5, + FOUR = 6, + FIVE = 7, + SIX = 8, + SEVEN = 9, + EIGHT = 10, + NINE = 11, + TEN = 12, + ELEVEN = 13, + TWELVE = 14, + THIRTEEN = 15, + FOURTEEN = 16, + FIFTEEN = 17, + SIXTEEN = 18, +}; + +class CTSensor : public sensor::Sensor { + public: + void set_phase(PhaseConfig *phase) { this->phase_ = phase; }; + const PhaseConfig *get_phase() const { return this->phase_; } + void set_input_port(CTInputPort input_port) { this->input_port_ = input_port; }; + CTInputPort get_input_port() const { return this->input_port_; } + + void update_from_reading(const SensorReading &sensor_reading); + float get_calibrated_power(int32_t raw_power) const; + + protected: + PhaseConfig *phase_; + CTInputPort input_port_; +}; + +} // namespace emporia_vue +} // namespace esphome + +#endif // ifdef USE_ESP32 diff --git a/esphome/components/emporia_vue/sensor.py b/esphome/components/emporia_vue/sensor.py new file mode 100644 index 000000000000..6fa790c5de21 --- /dev/null +++ b/esphome/components/emporia_vue/sensor.py @@ -0,0 +1,143 @@ +from esphome.components import sensor, i2c +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.components.ota import OTAComponent +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_INPUT, + CONF_OTA, + CONF_VOLTAGE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_WATT, + UNIT_VOLT, +) + +CONF_CT_CLAMPS = "ct_clamps" +CONF_PHASES = "phases" +CONF_PHASE_ID = "phase_id" +CONF_SENSOR_POLL_INTERVAL = "sensor_poll_interval" + +CODEOWNERS = ["@flaviut", "@Maelstrom96", "@krconv"] +ESP_PLATFORMS = ["esp-idf"] +DEPENDENCIES = ["i2c"] +AUTOLOAD = ["sensor"] + +emporia_vue_ns = cg.esphome_ns.namespace("emporia_vue") +EmporiaVueComponent = emporia_vue_ns.class_( + "EmporiaVueComponent", cg.Component, i2c.I2CDevice +) +PhaseConfig = emporia_vue_ns.class_("PhaseConfig") +CTSensor = emporia_vue_ns.class_("CTSensor", sensor.Sensor) + +PhaseInputWire = emporia_vue_ns.enum("PhaseInputWire") +PHASE_INPUT = { + "BLACK": PhaseInputWire.BLACK, + "RED": PhaseInputWire.RED, + "BLUE": PhaseInputWire.BLUE, +} + +CTInputPort = emporia_vue_ns.enum("CTInputPort") +CT_INPUT = { + "A": CTInputPort.A, + "B": CTInputPort.B, + "C": CTInputPort.C, + "1": CTInputPort.ONE, + "2": CTInputPort.TWO, + "3": CTInputPort.THREE, + "4": CTInputPort.FOUR, + "5": CTInputPort.FIVE, + "6": CTInputPort.SIX, + "7": CTInputPort.SEVEN, + "8": CTInputPort.EIGHT, + "9": CTInputPort.NINE, + "10": CTInputPort.TEN, + "11": CTInputPort.ELEVEN, + "12": CTInputPort.TWELVE, + "13": CTInputPort.THIRTEEN, + "14": CTInputPort.FOURTEEN, + "15": CTInputPort.FIFTEEN, + "16": CTInputPort.SIXTEEN, +} + +SCHEMA_CT_CLAMP = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, +).extend( + { + cv.GenerateID(): cv.declare_id(CTSensor), + cv.Required(CONF_PHASE_ID): cv.use_id(PhaseConfig), + cv.Required(CONF_INPUT): cv.enum(CT_INPUT), + } +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(EmporiaVueComponent), + cv.OnlyWith(CONF_OTA, "ota"): cv.use_id(OTAComponent), + cv.Optional( + CONF_SENSOR_POLL_INTERVAL, default="240ms" + ): cv.positive_time_period_milliseconds, + cv.Required(CONF_PHASES): cv.ensure_list( + { + cv.Required(CONF_ID): cv.declare_id(PhaseConfig), + cv.Required(CONF_INPUT): cv.enum(PHASE_INPUT), + cv.Optional(CONF_CALIBRATION, default=0.022): cv.zero_to_one_float, + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + accuracy_decimals=1, + ), + } + ), + cv.Required(CONF_CT_CLAMPS): cv.ensure_list(SCHEMA_CT_CLAMP), + }, + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x64)), + cv.only_with_esp_idf, + cv.only_on_esp32, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_sensor_poll_interval(config[CONF_SENSOR_POLL_INTERVAL])) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + phases = [] + for phase_config in config[CONF_PHASES]: + phase_var = cg.new_Pvariable(phase_config[CONF_ID], PhaseConfig()) + cg.add(phase_var.set_input_wire(phase_config[CONF_INPUT])) + cg.add(phase_var.set_calibration(phase_config[CONF_CALIBRATION])) + + if CONF_VOLTAGE in phase_config: + voltage_sensor = await sensor.new_sensor(phase_config[CONF_VOLTAGE]) + cg.add(phase_var.set_voltage_sensor(voltage_sensor)) + + phases.append(phase_var) + cg.add(var.set_phases(phases)) + + ct_sensors = [] + for ct_config in config[CONF_CT_CLAMPS]: + power_var = cg.new_Pvariable(ct_config[CONF_ID], CTSensor()) + phase_var = await cg.get_variable(ct_config[CONF_PHASE_ID]) + cg.add(power_var.set_phase(phase_var)) + cg.add(power_var.set_input_port(ct_config[CONF_INPUT])) + + await sensor.register_sensor(power_var, ct_config) + + ct_sensors.append(power_var) + cg.add(var.set_ct_sensors(ct_sensors)) + + if CONF_OTA in config: + ota = await cg.get_variable(config[CONF_OTA]) + cg.add_define("USING_OTA_COMPONENT") + cg.add_define("USE_OTA_STATE_CALLBACK") + cg.add(var.set_ota(ota)) diff --git a/tests/test5.yaml b/tests/test5.yaml index 37e65e7da27a..662a03578178 100644 --- a/tests/test5.yaml +++ b/tests/test5.yaml @@ -180,6 +180,24 @@ sensor: co2: name: CO2 Sensor + - platform: emporia_vue + phases: + - id: phase_a + input: BLACK + voltage: + name: "Phase A Voltage" + - id: phase_c + input: RED + voltage: + name: "Phase C Voltage" + ct_clamps: + - name: "A" + phase_id: phase_a + input: "A" + - name: "C" + phase_id: phase_c + input: "C" + script: - id: automation_test then: