diff --git a/CODEOWNERS b/CODEOWNERS index 810907492f3..29d35a679f4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -355,6 +355,7 @@ esphome/components/ld2450/* @hareeshmu esphome/components/ld2460/* @jesusvallejo esphome/components/ld24xx/* @kbx81 esphome/components/ld6002b/* @hepter +esphome/components/ld6004/* @wolph esphome/components/ld600x/* @hepter @wolph esphome/components/ledc/* @OttoWinter esphome/components/libretiny/* @kuba2k2 diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 1fe5bc33add..751c332adf6 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -35,14 +35,10 @@ bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) if (!(report_results_(res, addr_str))) { continue; } - if (res->temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res->temperature); - if (res->humidity.has_value() && this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(*res->humidity); - if (res->battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res->battery_level); - if (res->battery_voltage.has_value() && this->battery_voltage_sensor_ != nullptr) - this->battery_voltage_sensor_->publish_state(*res->battery_voltage); + publish_optional(this->temperature_sensor_, res->temperature); + publish_optional(this->humidity_sensor_, res->humidity); + publish_optional(this->battery_level_sensor_, res->battery_level); + publish_optional(this->battery_voltage_sensor_, res->battery_voltage); success = true; } PUBLISH_STATE(this->signal_strength_sensor_, device.get_rssi()); diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 5079115a17f..ce1e16b185d 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -5,6 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include @@ -51,40 +52,18 @@ static const uint8_t BME280_MODE_FORCED = 0b01; static const uint8_t BME280_SOFT_RESET = 0xB6; static const uint8_t BME280_STATUS_IM_UPDATE = 0b01; -const char *iir_filter_to_str(BME280IIRFilter filter) { // NOLINT - switch (filter) { - case BME280_IIR_FILTER_OFF: - return "OFF"; - case BME280_IIR_FILTER_2X: - return "2x"; - case BME280_IIR_FILTER_4X: - return "4x"; - case BME280_IIR_FILTER_8X: - return "8x"; - case BME280_IIR_FILTER_16X: - return "16x"; - default: - return "UNKNOWN"; - } +// Indexed by BME280IIRFilter +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "2x", "4x", "8x", "16x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *iir_filter_to_str(BME280IIRFilter value) { + return IIRFilterStrings::get_log_str(static_cast(value), IIRFilterStrings::LAST_INDEX); } -const char *oversampling_to_str(BME280Oversampling oversampling) { // NOLINT - switch (oversampling) { - case BME280_OVERSAMPLING_NONE: - return "None"; - case BME280_OVERSAMPLING_1X: - return "1x"; - case BME280_OVERSAMPLING_2X: - return "2x"; - case BME280_OVERSAMPLING_4X: - return "4x"; - case BME280_OVERSAMPLING_8X: - return "8x"; - case BME280_OVERSAMPLING_16X: - return "16x"; - default: - return "UNKNOWN"; - } +// Indexed by BME280Oversampling +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "1x", "2x", "4x", "8x", "16x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *oversampling_to_str(BME280Oversampling value) { + return OversamplingStrings::get_log_str(static_cast(value), OversamplingStrings::LAST_INDEX); } void BME280Component::setup() { @@ -176,15 +155,15 @@ void BME280Component::setup() { } void BME280Component::dump_config() { ESP_LOGCONFIG(TAG, "BME280:"); - ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); + ESP_LOGCONFIG(TAG, " IIR Filter: %s", LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->temperature_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->temperature_oversampling_))); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_))); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->humidity_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->humidity_oversampling_))); } inline uint8_t oversampling_to_time(BME280Oversampling over_sampling) { return (1 << uint8_t(over_sampling)) >> 1; } diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index f17c04fcd7d..c0919a1f12d 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -2,6 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::bme680 { @@ -30,46 +31,18 @@ constexpr float BME680_GAS_LOOKUP_TABLE_1[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0 constexpr float BME680_GAS_LOOKUP_TABLE_2[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0.1, 0.7, 0.0, -0.8, -0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; -[[maybe_unused]] static const char *oversampling_to_str(BME680Oversampling oversampling) { - switch (oversampling) { - case BME680_OVERSAMPLING_NONE: - return "None"; - case BME680_OVERSAMPLING_1X: - return "1x"; - case BME680_OVERSAMPLING_2X: - return "2x"; - case BME680_OVERSAMPLING_4X: - return "4x"; - case BME680_OVERSAMPLING_8X: - return "8x"; - case BME680_OVERSAMPLING_16X: - return "16x"; - default: - return "UNKNOWN"; - } +// Indexed by BME680Oversampling +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "1x", "2x", "4x", "8x", "16x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *oversampling_to_str(BME680Oversampling value) { + return OversamplingStrings::get_log_str(static_cast(value), OversamplingStrings::LAST_INDEX); } -[[maybe_unused]] static const char *iir_filter_to_str(BME680IIRFilter filter) { - switch (filter) { - case BME680_IIR_FILTER_OFF: - return "OFF"; - case BME680_IIR_FILTER_1X: - return "1x"; - case BME680_IIR_FILTER_3X: - return "3x"; - case BME680_IIR_FILTER_7X: - return "7x"; - case BME680_IIR_FILTER_15X: - return "15x"; - case BME680_IIR_FILTER_31X: - return "31x"; - case BME680_IIR_FILTER_63X: - return "63x"; - case BME680_IIR_FILTER_127X: - return "127x"; - default: - return "UNKNOWN"; - } +// Indexed by BME680IIRFilter +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "1x", "3x", "7x", "15x", "31x", "63x", "127x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *iir_filter_to_str(BME680IIRFilter value) { + return IIRFilterStrings::get_log_str(static_cast(value), IIRFilterStrings::LAST_INDEX); } void BME680Component::setup() { @@ -180,15 +153,15 @@ bool BME680Component::setup_registers_() { void BME680Component::dump_config() { ESP_LOGCONFIG(TAG, "BME680:"); LOG_I2C_DEVICE(this); - ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); + ESP_LOGCONFIG(TAG, " IIR Filter: %s", LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->temperature_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->temperature_oversampling_))); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_))); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->humidity_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->humidity_oversampling_))); LOG_SENSOR(" ", "Gas Resistance", this->gas_resistance_sensor_); if (this->heater_duration_ == 0 || this->heater_temperature_ == 0) { ESP_LOGCONFIG(TAG, " Heater OFF"); diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 346eba0fb68..eb2227ef18b 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,6 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::bmp280_base { @@ -20,40 +21,18 @@ static const uint8_t BMP280_MODE_FORCED = 0b01; static const uint8_t BMP280_SOFT_RESET = 0xB6; static const uint8_t BMP280_STATUS_IM_UPDATE = 0b01; -static const char *oversampling_to_str(BMP280Oversampling oversampling) { - switch (oversampling) { - case BMP280_OVERSAMPLING_NONE: - return "None"; - case BMP280_OVERSAMPLING_1X: - return "1x"; - case BMP280_OVERSAMPLING_2X: - return "2x"; - case BMP280_OVERSAMPLING_4X: - return "4x"; - case BMP280_OVERSAMPLING_8X: - return "8x"; - case BMP280_OVERSAMPLING_16X: - return "16x"; - default: - return "UNKNOWN"; - } +// Indexed by BMP280Oversampling +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "1x", "2x", "4x", "8x", "16x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *oversampling_to_str(BMP280Oversampling value) { + return OversamplingStrings::get_log_str(static_cast(value), OversamplingStrings::LAST_INDEX); } -static const char *iir_filter_to_str(BMP280IIRFilter filter) { - switch (filter) { - case BMP280_IIR_FILTER_OFF: - return "OFF"; - case BMP280_IIR_FILTER_2X: - return "2x"; - case BMP280_IIR_FILTER_4X: - return "4x"; - case BMP280_IIR_FILTER_8X: - return "8x"; - case BMP280_IIR_FILTER_16X: - return "16x"; - default: - return "UNKNOWN"; - } +// Indexed by BMP280IIRFilter +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "2x", "4x", "8x", "16x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *iir_filter_to_str(BMP280IIRFilter value) { + return IIRFilterStrings::get_log_str(static_cast(value), IIRFilterStrings::LAST_INDEX); } void BMP280Component::setup() { @@ -124,13 +103,13 @@ void BMP280Component::setup() { } void BMP280Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP280:"); - ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); + ESP_LOGCONFIG(TAG, " IIR Filter: %s", LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->temperature_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->temperature_oversampling_))); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_))); } inline uint8_t oversampling_to_time(BMP280Oversampling over_sampling) { return (1 << uint8_t(over_sampling)) >> 1; } diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index a3f7ddf3dc7..4140b2b7e5d 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -33,9 +33,8 @@ void GCJA5Component::loop() { // check if rx_message_ has 32 bytes of data if (this->rx_message_.size() == 32) { - this->parse_data_(); - - if (this->have_good_data_) { + if (this->parse_data_()) { + this->status_clear_warning(); PUBLISH_STATE(this->pm_1_0_sensor_, get_32_bit_uint_(1)); PUBLISH_STATE(this->pm_2_5_sensor_, get_32_bit_uint_(5)); PUBLISH_STATE(this->pm_10_0_sensor_, get_32_bit_uint_(9)); @@ -64,7 +63,7 @@ bool GCJA5Component::calculate_checksum_() { return (crc == this->rx_message_[30]); } -void GCJA5Component::parse_data_() { +bool GCJA5Component::parse_data_() { ESP_LOGVV(TAG, "GCJA5 Data: "); for (uint8_t i = 0; i < 32; i++) { ESP_LOGVV(TAG, " %u: 0b" BYTE_TO_BINARY_PATTERN " (0x%02X)", i + 1, BYTE_TO_BINARY(this->rx_message_[i]), @@ -73,12 +72,10 @@ void GCJA5Component::parse_data_() { if (this->rx_message_[0] != 0x02 || this->rx_message_[31] != 0x03 || !this->calculate_checksum_()) { ESP_LOGVV(TAG, "Discarding bad packet - failed checks."); - return; - } else { - ESP_LOGVV(TAG, "Good packet found."); + return false; } + ESP_LOGVV(TAG, "Good packet found."); - this->have_good_data_ = true; uint8_t status = this->rx_message_[29]; if (!this->first_status_log_) { this->first_status_log_ = true; @@ -91,6 +88,7 @@ void GCJA5Component::parse_data_() { " Fan Status : %i", (status >> 6) & 0x03, (status >> 4) & 0x03, (status >> 2) & 0x03, (status >> 0) & 0x03); } + return true; } void GCJA5Component::dump_config() { ; } diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 422134cb01f..b7c21d28359 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -23,7 +23,7 @@ class GCJA5Component final : public Component, public uart::UARTDevice { void loop() override; protected: - void parse_data_(); + bool parse_data_(); bool calculate_checksum_(); uint16_t get_16_bit_uint_(uint8_t start_index) const { @@ -36,7 +36,6 @@ class GCJA5Component final : public Component, public uart::UARTDevice { uint32_t last_transmission_{0}; std::vector rx_message_; - bool have_good_data_{false}; bool first_status_log_{false}; }; diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index b8f153ac375..4a04690857a 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -174,15 +174,15 @@ void LD6002BComponent::wake_() { } #ifdef USE_SWITCH -void LD6002BComponent::set_switch_state(switch_::Switch *entity, bool state) { - if (entity != this->low_power_switch_) { - this->LD600XComponent::set_switch_state(entity, state); - return; - } +bool LD6002BComponent::set_switch_state(switch_::Switch *entity, bool state) { + if (entity != this->low_power_switch_) + return this->LD600XComponent::set_switch_state(entity, state); + if (!this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF)) + return false; this->low_power_enabled_ = state; this->low_power_reported_ = true; - this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF); this->update_work_mode_fallback_(); + return true; } #endif diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 74c45c7a28e..8c0a6ef8d8a 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -21,7 +21,7 @@ class LD6002BComponent final : public ld600x::LD600XComponent { #ifdef USE_SWITCH SUB_SWITCH(low_power) - void set_switch_state(switch_::Switch *entity, bool state) override; + bool set_switch_state(switch_::Switch *entity, bool state) override; #endif #ifdef USE_BUTTON void press_button(uint8_t kind) override; diff --git a/esphome/components/ld6004/__init__.py b/esphome/components/ld6004/__init__.py new file mode 100644 index 00000000000..19c404f3a08 --- /dev/null +++ b/esphome/components/ld6004/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import uart +from esphome.components.ld600x import ( + LD600XComponent, + ld600x_hub_schema, + request_model_sizes, + uart_final_validate, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ld600x"] +CODEOWNERS = ["@wolph"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld6004" +MULTI_CONF = True + +ld6004_ns = cg.esphome_ns.namespace("ld6004") +LD6004Component = ld6004_ns.class_("LD6004Component", LD600XComponent) + +CONFIG_SCHEMA = ld600x_hub_schema(LD6004Component) +FINAL_VALIDATE_SCHEMA = uart_final_validate("ld6004") + + +async def to_code(config: ConfigType) -> None: + var = await uart.new_uart_component(config) + # The dwell areas take a third kind, which the platforms request only when they use them. + request_model_sizes(var, area_kinds=2) diff --git a/esphome/components/ld6004/binary_sensor.py b/esphome/components/ld6004/binary_sensor.py new file mode 100644 index 00000000000..1e76e43c447 --- /dev/null +++ b/esphome/components/ld6004/binary_sensor.py @@ -0,0 +1,13 @@ +from esphome.components.ld600x import entities +from esphome.types import ConfigType + +from . import LD6004Component +from .const import CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONFIG_SCHEMA = entities.binary_sensor_schema(LD6004Component, CONF_LD6004_ID) + + +async def to_code(config: ConfigType) -> None: + await entities.binary_sensor_to_code(config, CONF_LD6004_ID) diff --git a/esphome/components/ld6004/const.py b/esphome/components/ld6004/const.py new file mode 100644 index 00000000000..06206c48b61 --- /dev/null +++ b/esphome/components/ld6004/const.py @@ -0,0 +1,2 @@ +AREA_KINDS = ("interference", "detection", "dwell") +CONF_LD6004_ID = "ld6004_id" diff --git a/esphome/components/ld6004/ld6004.cpp b/esphome/components/ld6004/ld6004.cpp new file mode 100644 index 00000000000..4123479a272 --- /dev/null +++ b/esphome/components/ld6004/ld6004.cpp @@ -0,0 +1,197 @@ +#include "ld6004.h" +#include "esphome/components/ld600x/ld600x_frame.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include + +namespace esphome::ld6004 { + +ESPHOME_LOG_TAG(TAG, "ld6004"); + +static constexpr uint16_t TYPE_SET_DWELL_LIFETIME = 0x0206; +static constexpr uint16_t TYPE_SET_OUTPUT_INTERVAL = 0x0207; +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A12; +static constexpr uint16_t TYPE_REPORT_WORK_STATUS = 0x0A14; +static constexpr uint16_t TYPE_REPORT_P20_MODE = 0x0A15; +static constexpr uint16_t TYPE_REPORT_DWELL_LIFETIME = 0x0A17; +static constexpr uint16_t TYPE_REPORT_OUTPUT_INTERVAL = 0x0A18; + +static constexpr uint32_t CMD_WORK_LOW_POWER = 0x16; +static constexpr uint32_t CMD_WORK_NORMAL = 0x17; +static constexpr uint32_t CMD_GET_WORK_MODE = 0x18; +static constexpr uint32_t CMD_WORK_RADAR_OFF_P20_HIGH = 0x1B; +static constexpr uint32_t CMD_WORK_RADAR_OFF_P20_LOW = 0x1C; +static constexpr uint32_t CMD_P20_PRESENCE_HIGH = 0x1D; +static constexpr uint32_t CMD_P20_PRESENCE_LOW = 0x1E; +static constexpr uint32_t CMD_GET_P20_MODE = 0x1F; +static constexpr uint32_t CMD_P20_CONSTANT_LOW = 0x20; +static constexpr uint32_t CMD_P20_CONSTANT_HIGH = 0x21; +static constexpr uint32_t CMD_P20_PULSE_LOW = 0x22; +static constexpr uint32_t CMD_P20_PULSE_HIGH = 0x23; +static constexpr uint32_t CMD_WORK_HIGH_REFLECTIVITY = 0x24; +static constexpr uint32_t CMD_CLEAR_DWELL = 0x25; +static constexpr uint32_t CMD_GET_DWELL_LIFETIME = 0x26; +static constexpr uint32_t CMD_GET_OUTPUT_INTERVAL = 0x27; + +// The work_mode select options, the 0x0A12 report values and the tables below share one order. +static constexpr uint8_t WORK_MODE_COUNT = 5; +static constexpr uint8_t WORK_MODE_RADAR_OFF_P20_HIGH = 2; +static constexpr uint8_t WORK_MODE_RADAR_OFF_P20_LOW = 3; +static constexpr uint8_t P20_MODE_COUNT = 6; +#ifdef USE_SELECT +// Every control code fits in a byte, so the tables are bytes in flash. +static constexpr uint8_t WORK_MODE_COMMANDS[WORK_MODE_COUNT] PROGMEM = { + CMD_WORK_NORMAL, CMD_WORK_LOW_POWER, CMD_WORK_RADAR_OFF_P20_HIGH, CMD_WORK_RADAR_OFF_P20_LOW, + CMD_WORK_HIGH_REFLECTIVITY}; +static constexpr uint8_t P20_MODE_COMMANDS[P20_MODE_COUNT] PROGMEM = {CMD_P20_PRESENCE_HIGH, CMD_P20_PRESENCE_LOW, + CMD_P20_CONSTANT_LOW, CMD_P20_CONSTANT_HIGH, + CMD_P20_PULSE_LOW, CMD_P20_PULSE_HIGH}; +#endif +#ifdef USE_TEXT_SENSOR +PROGMEM_STRING_TABLE(WorkModeStrings, "normal", "low_power", "radar_off_p20_high", "radar_off_p20_low", + "high_reflectivity"); +static_assert(WorkModeStrings::COUNT == WORK_MODE_COUNT); +#endif + +bool LD6004Component::handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) { + switch (type) { + case TYPE_REPORT_WORK_MODE: { + if (len != 1 && len != 4) + break; + const uint32_t mode = len == 4 ? encode_value_le(data) : data[0]; + if (mode >= WORK_MODE_COUNT) + break; + this->radar_off_ = mode == WORK_MODE_RADAR_OFF_P20_HIGH || mode == WORK_MODE_RADAR_OFF_P20_LOW; + if (this->radar_off_) { + // Detection has stopped, so no later report clears what the last ones showed. + this->clear_area_presence_(); + this->clear_target_state_(); + } +#ifdef USE_SELECT + PUBLISH_STATE(this->work_mode_select_, mode); +#endif +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ != nullptr) + this->work_mode_text_sensor_->publish_state(WorkModeStrings::get_progmem_str(static_cast(mode), 0)); +#endif + return true; + } + case TYPE_REPORT_TARGET: + case TYPE_REPORT_AREA_PRESENCE: + // Claimed while the radar is off: frames still in flight must not restore the cleared presence. + return this->radar_off_; + case TYPE_REPORT_WORK_STATUS: + // 0x0A14 marks the unattended low-power transition; a zero means nobody is in any area. + if (len >= 1 && data[0] == 0 && !this->target_presence_any_) + this->clear_area_presence_(); + return true; + case TYPE_REPORT_P20_MODE: + if (len != 1 || data[0] >= P20_MODE_COUNT) + break; +#ifdef USE_SELECT + PUBLISH_STATE(this->p20_mode_select_, data[0]); +#endif + return true; + case TYPE_REPORT_DWELL_LIFETIME: + if (len != 4) + break; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->dwell_lifetime_number_, encode_value_le(data)); +#endif + return true; + case TYPE_REPORT_OUTPUT_INTERVAL: + if (len != 4) + break; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->output_interval_number_, encode_value_le(data)); +#endif + return true; + default: + return false; + } + // Still claimed, so the base does not treat it as an unknown frame. + ESP_LOGD(TAG, "Unexpected report 0x%04X: %u bytes, first byte 0x%02X", type, len, len > 0 ? data[0] : 0); + return true; +} + +#ifdef USE_NUMBER +void LD6004Component::set_number_value(number::Number *entity, float value) { + if (entity != this->dwell_lifetime_number_ && entity != this->output_interval_number_) { + this->LD600XComponent::set_number_value(entity, value); + return; + } + // NumberCall already keeps the value inside the traits' range; the module takes whole numbers only. + if (std::floor(value) != value) { + ESP_LOGW(TAG, "Invalid number value: %f", value); + return; + } + uint8_t data[4]; + ld600x::write_u32_le(data, static_cast(value)); + this->queue_command_(entity == this->dwell_lifetime_number_ ? TYPE_SET_DWELL_LIFETIME : TYPE_SET_OUTPUT_INTERVAL, + data, sizeof(data)); +} +#endif // USE_NUMBER + +#ifdef USE_SELECT +void LD6004Component::set_select_value(select::Select *entity, size_t index) { + if (entity == this->work_mode_select_) { + if (index < WORK_MODE_COUNT) + this->send_control_command_(progmem_read_byte(&WORK_MODE_COMMANDS[index])); + } else if (entity == this->p20_mode_select_) { + if (index < P20_MODE_COUNT) + this->send_control_command_(progmem_read_byte(&P20_MODE_COMMANDS[index])); + } else { + this->LD600XComponent::set_select_value(entity, index); + } +} +#endif // USE_SELECT + +#ifdef USE_BUTTON +void LD6004Component::press_button(uint8_t kind) { + if (kind == BUTTON_CLEAR_DWELL) { + if (this->send_control_command_(CMD_CLEAR_DWELL)) { + this->LD600XComponent::press_button(ld600x::BUTTON_GET_AREAS); + } + } else { + this->LD600XComponent::press_button(kind); + } +} +#endif // USE_BUTTON + +void LD6004Component::setup_model() { + bool want_work_mode = false; +#ifdef USE_SELECT + want_work_mode = this->work_mode_select_ != nullptr; + if (this->p20_mode_select_ != nullptr) + this->send_control_command_(CMD_GET_P20_MODE); +#endif +#ifdef USE_TEXT_SENSOR + // The text sensor follows the 0x0A12 report too, so it needs the same boot query as the select + // to show a mode before the module next changes it. + want_work_mode = want_work_mode || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_work_mode) + this->send_control_command_(CMD_GET_WORK_MODE); +#ifdef USE_NUMBER + if (this->dwell_lifetime_number_ != nullptr) + this->send_control_command_(CMD_GET_DWELL_LIFETIME); + if (this->output_interval_number_ != nullptr) + this->send_control_command_(CMD_GET_OUTPUT_INTERVAL); +#endif +} + +void LD6004Component::dump_model_config() { +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Dwell Lifetime", this->dwell_lifetime_number_); + LOG_NUMBER(" ", "Output Interval", this->output_interval_number_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Work Mode", this->work_mode_select_); + LOG_SELECT(" ", "P20 Mode", this->p20_mode_select_); +#endif +} + +} // namespace esphome::ld6004 diff --git a/esphome/components/ld6004/ld6004.h b/esphome/components/ld6004/ld6004.h new file mode 100644 index 00000000000..fa08a3387a5 --- /dev/null +++ b/esphome/components/ld6004/ld6004.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/components/ld600x/ld600x.h" + +namespace esphome::ld6004 { + +enum LD6004ButtonType : uint8_t { + BUTTON_CLEAR_DWELL = ld600x::LD600X_MODEL_KIND_BASE, +}; + +class LD6004Component final : public ld600x::LD600XComponent { + public: + LD6004Component() : LD600XComponent(LOG_STR("ld6004")) {} + +#ifdef USE_NUMBER + SUB_NUMBER(dwell_lifetime) + SUB_NUMBER(output_interval) +#endif +#ifdef USE_SELECT + SUB_SELECT(work_mode) + SUB_SELECT(p20_mode) +#endif + +#ifdef USE_NUMBER + void set_number_value(number::Number *entity, float value) override; +#endif +#ifdef USE_SELECT + void set_select_value(select::Select *entity, size_t index) override; +#endif +#ifdef USE_BUTTON + void press_button(uint8_t kind) override; +#endif + + protected: + bool handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) override; + void setup_model() override; + void dump_model_config() override; + + // Set by a work mode report that turns the radar off, and cleared by one that turns it back on. + bool radar_off_{false}; +}; + +} // namespace esphome::ld6004 diff --git a/esphome/components/ld6004/sensor.py b/esphome/components/ld6004/sensor.py new file mode 100644 index 00000000000..907b9f2ac45 --- /dev/null +++ b/esphome/components/ld6004/sensor.py @@ -0,0 +1,15 @@ +from esphome.components.ld600x import entities +from esphome.types import ConfigType + +from . import LD6004Component +from .const import AREA_KINDS, CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONFIG_SCHEMA = entities.sensor_schema( + LD6004Component, CONF_LD6004_ID, area_kinds=AREA_KINDS +) + + +async def to_code(config: ConfigType) -> None: + await entities.sensor_to_code(config, CONF_LD6004_ID, area_kinds=AREA_KINDS) diff --git a/esphome/components/ld600x/entities.py b/esphome/components/ld600x/entities.py index c3c1ebb190a..c2999b75131 100644 --- a/esphome/components/ld600x/entities.py +++ b/esphome/components/ld600x/entities.py @@ -39,7 +39,7 @@ from esphome.cpp_generator import MockObj, MockObjClass, ParameterListExpression import esphome.final_validate as fv from esphome.types import ConfigType -from . import AreaAxis, AreaKind, ButtonType +from . import AreaAxis, AreaKind, ButtonType, request_model_sizes from .const import ( AREA_COUNT, CONF_APPLY_AREA, @@ -196,14 +196,19 @@ async def sensor_to_code( if conf := target_config.get(key): cg.add(setter(i, await sensor.new_sensor(conf))) - for kind in area_kinds: + used_kinds = 0 + for kind_index, kind in enumerate(area_kinds): area_kind = _AREA_KINDS[kind] for i in range(AREA_COUNT): if area_config := config.get(f"{kind}_area_{i}"): + used_kinds = kind_index + 1 for key, axis in _AREA_AXES: if conf := area_config.get(key): sens = await sensor.new_sensor(conf) cg.add(hub.set_area_sensor(area_kind, i, axis, sens)) + # The hub sizes the common kinds; a later kind such as dwell is sized only when used. + if used_kinds: + request_model_sizes(hub, area_kinds=used_kinds) _OCCUPANCY_SCHEMA = binary_sensor.binary_sensor_schema( @@ -425,21 +430,22 @@ async def select_to_code( return selects -CONFIG_SWITCH_SCHEMA = switch.switch_schema( - switch.ApplySwitch, - block_inverted=True, - device_class=DEVICE_CLASS_SWITCH, - entity_category=ENTITY_CATEGORY_CONFIG, -) - -_SWITCH_SCHEMAS = { - CONF_POINT_CLOUD: CONFIG_SWITCH_SCHEMA, - CONF_TARGET_DISPLAY: switch.switch_schema( +def _config_switch_schema(**kwargs: Any) -> cv.Schema: + return switch.switch_schema( switch.ApplySwitch, block_inverted=True, device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, - default_restore_mode="RESTORE_DEFAULT_ON", + **kwargs, + ) + + +CONFIG_SWITCH_SCHEMA = _config_switch_schema() + +_SWITCH_SCHEMAS = { + CONF_POINT_CLOUD: CONFIG_SWITCH_SCHEMA, + CONF_TARGET_DISPLAY: _config_switch_schema( + default_restore_mode="RESTORE_DEFAULT_ON" ), } @@ -448,47 +454,42 @@ def switch_schema( hub_class: MockObjClass, hub_id_key: str, *, - keys: tuple[str, ...] | None = None, extra: dict[Any, Any] | None = None, ) -> cv.Schema: return cv.Schema( { cv.GenerateID(hub_id_key): cv.use_id(hub_class), - **{ - cv.Optional(key): schema - for key, schema in _SWITCH_SCHEMAS.items() - if keys is None or key in keys - }, + **{cv.Optional(key): schema for key, schema in _SWITCH_SCHEMAS.items()}, } ).extend(extra or {}) SWITCH_CONTROL: dict[str, str] = { "apply": "set_switch_state(entity, state)", - "publish": "after", + # A full command queue rejects the write, and the switch then keeps its old state. + "publish": "on_success", } async def switch_to_code(config: ConfigType, hub_id_key: str) -> None: hub = await cg.get_variable(config[hub_id_key]) - # The schema only accepts the model's keys, so a missing one is skipped here. switches = switch.sub_switches(config, parent=hub) await switches(CONF_POINT_CLOUD, hub.set_point_cloud_switch, **SWITCH_CONTROL) await switches(CONF_TARGET_DISPLAY, hub.set_target_display_switch, **SWITCH_CONTROL) -_CONFIG_BUTTON_SCHEMA = button.button_schema( +CONFIG_BUTTON_SCHEMA = button.button_schema( button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG ) DIAGNOSTIC_BUTTON_SCHEMA = button.button_schema( button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ) _BUTTON_SCHEMAS = { - CONF_APPLY_AREA: _CONFIG_BUTTON_SCHEMA, - CONF_AUTO_INTERFERENCE: _CONFIG_BUTTON_SCHEMA, + CONF_APPLY_AREA: CONFIG_BUTTON_SCHEMA, + CONF_AUTO_INTERFERENCE: CONFIG_BUTTON_SCHEMA, CONF_GET_AREAS: DIAGNOSTIC_BUTTON_SCHEMA, - CONF_CLEAR_INTERFERENCE: _CONFIG_BUTTON_SCHEMA, - CONF_RESET_DETECTION_AREA: _CONFIG_BUTTON_SCHEMA, + CONF_CLEAR_INTERFERENCE: CONFIG_BUTTON_SCHEMA, + CONF_RESET_DETECTION_AREA: CONFIG_BUTTON_SCHEMA, CONF_GET_DELAY: DIAGNOSTIC_BUTTON_SCHEMA, CONF_GET_SENSITIVITY: DIAGNOSTIC_BUTTON_SCHEMA, CONF_GET_TRIGGER_SPEED: DIAGNOSTIC_BUTTON_SCHEMA, @@ -496,7 +497,7 @@ _BUTTON_SCHEMAS = { CONF_GET_INSTALLATION: DIAGNOSTIC_BUTTON_SCHEMA, CONF_GET_LOW_POWER_MODE: DIAGNOSTIC_BUTTON_SCHEMA, CONF_GET_LOW_POWER_SLEEP_TIME: DIAGNOSTIC_BUTTON_SCHEMA, - CONF_RESET_UNATTENDED: _CONFIG_BUTTON_SCHEMA, + CONF_RESET_UNATTENDED: CONFIG_BUTTON_SCHEMA, } @@ -504,17 +505,12 @@ def button_schema( hub_class: MockObjClass, hub_id_key: str, *, - keys: tuple[str, ...] | None = None, extra: dict[Any, Any] | None = None, ) -> cv.Schema: return cv.Schema( { cv.GenerateID(hub_id_key): cv.use_id(hub_class), - **{ - cv.Optional(key): schema - for key, schema in _BUTTON_SCHEMAS.items() - if keys is None or key in keys - }, + **{cv.Optional(key): schema for key, schema in _BUTTON_SCHEMAS.items()}, } ).extend(extra or {}) diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index 92ab90dd657..72f20590458 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -1380,10 +1380,11 @@ void LD600XComponent::clear_target_state_() { } #ifdef USE_SWITCH -void LD600XComponent::set_switch_state(switch_::Switch *entity, bool state) { +bool LD600XComponent::set_switch_state(switch_::Switch *entity, bool state) { if (entity == this->point_cloud_switch_) { + if (!this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF)) + return false; this->point_cloud_enabled_ = state; - this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); #ifdef USE_SENSOR // The count only moves while the stream runs, so the last one would stand as // a live reading. The dedup sentinel is cleared with it: the same count is @@ -1392,13 +1393,15 @@ void LD600XComponent::set_switch_state(switch_::Switch *entity, bool state) { this->point_count_sensor_.publish_state_unknown(); #endif } else { // Only registered entities call this, so the last one needs no compare + if (!this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF)) + return false; this->target_display_enabled_ = state; - this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF); if (!state) { // Every target entity is fed by the reports this just stopped. this->clear_target_state_(); } } + return true; } #endif diff --git a/esphome/components/ld600x/ld600x.h b/esphome/components/ld600x/ld600x.h index 9df55074fc6..7968c24c658 100644 --- a/esphome/components/ld600x/ld600x.h +++ b/esphome/components/ld600x/ld600x.h @@ -201,7 +201,8 @@ class LD600XComponent : public Component, public uart::UARTDevice { virtual void set_select_value(select::Select *entity, size_t index); #endif #ifdef USE_SWITCH - virtual void set_switch_state(switch_::Switch *entity, bool state); + // Returns false when the command was not queued, so the switch keeps its old state. + virtual bool set_switch_state(switch_::Switch *entity, bool state); #endif #ifdef USE_BUTTON virtual void press_button(uint8_t kind); diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index dffe8a72d7f..ade3b6548f7 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -35,14 +35,10 @@ bool PVVXMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) if (!(report_results_(res, addr_str))) { continue; } - if (res->temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res->temperature); - if (res->humidity.has_value() && this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(*res->humidity); - if (res->battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res->battery_level); - if (res->battery_voltage.has_value() && this->battery_voltage_sensor_ != nullptr) - this->battery_voltage_sensor_->publish_state(*res->battery_voltage); + publish_optional(this->temperature_sensor_, res->temperature); + publish_optional(this->humidity_sensor_, res->humidity); + publish_optional(this->battery_level_sensor_, res->battery_level); + publish_optional(this->battery_voltage_sensor_, res->battery_voltage); PUBLISH_STATE(this->signal_strength_sensor_, device.get_rssi()); success = true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index e15e91f6487..0c6ed3a4268 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -1,4 +1,5 @@ #include "qmp6988.h" +#include "esphome/core/progmem.h" #include #include @@ -45,46 +46,18 @@ static const uint32_t SUBTRACTOR = 8388608; ESPHOME_LOG_TAG(TAG, "qmp6988"); -static const char *oversampling_to_str(QMP6988Oversampling oversampling) { - switch (oversampling) { - case QMP6988_OVERSAMPLING_SKIPPED: - return "None"; - case QMP6988_OVERSAMPLING_1X: - return "1x"; - case QMP6988_OVERSAMPLING_2X: - return "2x"; - case QMP6988_OVERSAMPLING_4X: - return "4x"; - case QMP6988_OVERSAMPLING_8X: - return "8x"; - case QMP6988_OVERSAMPLING_16X: - return "16x"; - case QMP6988_OVERSAMPLING_32X: - return "32x"; - case QMP6988_OVERSAMPLING_64X: - return "64x"; - default: - return "UNKNOWN"; - } +// Indexed by QMP6988Oversampling +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "1x", "2x", "4x", "8x", "16x", "32x", "64x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *oversampling_to_str(QMP6988Oversampling value) { + return OversamplingStrings::get_log_str(static_cast(value), OversamplingStrings::LAST_INDEX); } -static const char *iir_filter_to_str(QMP6988IIRFilter filter) { - switch (filter) { - case QMP6988_IIR_FILTER_OFF: - return "OFF"; - case QMP6988_IIR_FILTER_2X: - return "2x"; - case QMP6988_IIR_FILTER_4X: - return "4x"; - case QMP6988_IIR_FILTER_8X: - return "8x"; - case QMP6988_IIR_FILTER_16X: - return "16x"; - case QMP6988_IIR_FILTER_32X: - return "32x"; - default: - return "UNKNOWN"; - } +// Indexed by QMP6988IIRFilter +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "2x", "4x", "8x", "16x", "32x", "UNKNOWN"); + +[[maybe_unused]] static const LogString *iir_filter_to_str(QMP6988IIRFilter value) { + return IIRFilterStrings::get_log_str(static_cast(value), IIRFilterStrings::LAST_INDEX); } bool QMP6988Component::device_check_() { @@ -327,12 +300,14 @@ void QMP6988Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - ESP_LOGCONFIG(TAG, " Temperature Oversampling: %s", oversampling_to_str(this->temperature_oversampling_)); + ESP_LOGCONFIG(TAG, " Temperature Oversampling: %s", + LOG_STR_ARG(oversampling_to_str(this->temperature_oversampling_))); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); ESP_LOGCONFIG(TAG, " Pressure Oversampling: %s\n" " IIR Filter: %s", - oversampling_to_str(this->pressure_oversampling_), iir_filter_to_str(this->iir_filter_)); + LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_)), + LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); } void QMP6988Component::update() { diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 9db88df82cc..ef95d717dc1 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -31,28 +31,17 @@ class RuuviTag final : public Component, public ble_device_base::ESPBTDeviceList if (!res.has_value()) return false; - if (res->humidity.has_value() && this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(*res->humidity); - if (res->temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res->temperature); - if (res->pressure.has_value() && this->pressure_sensor_ != nullptr) - this->pressure_sensor_->publish_state(*res->pressure); - if (res->acceleration.has_value() && this->acceleration_sensor_ != nullptr) - this->acceleration_sensor_->publish_state(*res->acceleration); - if (res->acceleration_x.has_value() && this->acceleration_x_sensor_ != nullptr) - this->acceleration_x_sensor_->publish_state(*res->acceleration_x); - if (res->acceleration_y.has_value() && this->acceleration_y_sensor_ != nullptr) - this->acceleration_y_sensor_->publish_state(*res->acceleration_y); - if (res->acceleration_z.has_value() && this->acceleration_z_sensor_ != nullptr) - this->acceleration_z_sensor_->publish_state(*res->acceleration_z); - if (res->battery_voltage.has_value() && this->battery_voltage_sensor_ != nullptr) - this->battery_voltage_sensor_->publish_state(*res->battery_voltage); - if (res->tx_power.has_value() && this->tx_power_sensor_ != nullptr) - this->tx_power_sensor_->publish_state(*res->tx_power); - if (res->movement_counter.has_value() && this->movement_counter_sensor_ != nullptr) - this->movement_counter_sensor_->publish_state(*res->movement_counter); - if (res->measurement_sequence_number.has_value() && this->measurement_sequence_number_sensor_ != nullptr) - this->measurement_sequence_number_sensor_->publish_state(*res->measurement_sequence_number); + publish_optional(this->humidity_sensor_, res->humidity); + publish_optional(this->temperature_sensor_, res->temperature); + publish_optional(this->pressure_sensor_, res->pressure); + publish_optional(this->acceleration_sensor_, res->acceleration); + publish_optional(this->acceleration_x_sensor_, res->acceleration_x); + publish_optional(this->acceleration_y_sensor_, res->acceleration_y); + publish_optional(this->acceleration_z_sensor_, res->acceleration_z); + publish_optional(this->battery_voltage_sensor_, res->battery_voltage); + publish_optional(this->tx_power_sensor_, res->tx_power); + publish_optional(this->movement_counter_sensor_, res->movement_counter); + publish_optional(this->measurement_sequence_number_sensor_, res->measurement_sequence_number); return true; } diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 5417f9ea6ce..dbaa5e3f425 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -30,12 +30,12 @@ SM10BIT_BASE_CONFIG_SCHEMA = cv.Schema( async def register_sm10bit_base(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = cg.new_Pvariable( + config[CONF_ID], + await cg.gpio_pin_expression(config[CONF_DATA_PIN]), + await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]), + config[CONF_MAX_POWER_COLOR_CHANNELS], + config[CONF_MAX_POWER_WHITE_CHANNELS], + ) await cg.register_component(var, config) - - await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) - await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) - cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) - cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) - return var diff --git a/esphome/components/sm10bit_base/sm10bit_base.cpp b/esphome/components/sm10bit_base/sm10bit_base.cpp index a796e99f860..d9b9410dd14 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.cpp +++ b/esphome/components/sm10bit_base/sm10bit_base.cpp @@ -1,16 +1,15 @@ #include "sm10bit_base.h" -#include "esphome/core/log.h" namespace esphome::sm10bit_base { -ESPHOME_LOG_TAG(TAG, "sm10bit_base"); +static constexpr uint8_t SM10BIT_MODEL_ID = 0xC0; -static const uint8_t SM10BIT_ADDR_STANDBY = 0x0; -static const uint8_t SM10BIT_ADDR_START_3CH = 0x8; -static const uint8_t SM10BIT_ADDR_START_2CH = 0x10; -static const uint8_t SM10BIT_ADDR_START_5CH = 0x18; +static constexpr uint8_t SM10BIT_ADDR_STANDBY = 0x0; +static constexpr uint8_t SM10BIT_ADDR_START_3CH = 0x8; +static constexpr uint8_t SM10BIT_ADDR_START_2CH = 0x10; +static constexpr uint8_t SM10BIT_ADDR_START_5CH = 0x18; -static const uint8_t SM10BIT_DELAY = 2; +static constexpr uint8_t SM10BIT_DELAY = 2; // Power current values // HEX | Binary | RGB level | White level | Config value @@ -31,63 +30,50 @@ static const uint8_t SM10BIT_DELAY = 2; // 0xE | 1110 | RGB 150mA | CW 75mA | 14 // 0xF | 1111 | RGB 160mA | CW 80mA | 15 -void Sm10BitBase::loop() { - if (!this->update_) - return; +void Sm10BitBase::setup() { + this->data_pin_->setup(); + this->data_pin_->digital_write(true); + this->clock_pin_->setup(); + this->clock_pin_->digital_write(true); +} - uint8_t data[12]; - if (this->pwm_amounts_[0] == 0 && this->pwm_amounts_[1] == 0 && this->pwm_amounts_[2] == 0 && - this->pwm_amounts_[3] == 0 && this->pwm_amounts_[4] == 0) { - for (int i = 1; i < 12; i++) - data[i] = 0; - // First turn all channels off - data[0] = this->model_id_ + SM10BIT_ADDR_START_5CH; - this->write_buffer_(data, 12); - // Then sleep - data[0] = this->model_id_ + SM10BIT_ADDR_STANDBY; - this->write_buffer_(data, 12); - } else if (this->pwm_amounts_[0] == 0 && this->pwm_amounts_[1] == 0 && this->pwm_amounts_[2] == 0 && - (this->pwm_amounts_[3] > 0 || this->pwm_amounts_[4] > 0)) { - // Only data on white channels - data[0] = this->model_id_ + SM10BIT_ADDR_START_2CH; - data[1] = 0 << 4 | this->max_power_white_channels_; - for (int i = 2, j = 0; i < 12; i += 2, j++) { - data[i] = this->pwm_amounts_[j] >> 0x8; - data[i + 1] = this->pwm_amounts_[j] & 0xFF; - } - this->write_buffer_(data, 12); - } else if ((this->pwm_amounts_[0] > 0 || this->pwm_amounts_[1] > 0 || this->pwm_amounts_[2] > 0) && - this->pwm_amounts_[3] == 0 && this->pwm_amounts_[4] == 0) { - // Only data on RGB channels - data[0] = this->model_id_ + SM10BIT_ADDR_START_3CH; - data[1] = this->max_power_color_channels_ << 4 | 0; - for (int i = 2, j = 0; i < 12; i += 2, j++) { - data[i] = this->pwm_amounts_[j] >> 0x8; - data[i + 1] = this->pwm_amounts_[j] & 0xFF; - } - this->write_buffer_(data, 12); - } else { - // All channels - data[0] = this->model_id_ + SM10BIT_ADDR_START_5CH; - data[1] = this->max_power_color_channels_ << 4 | this->max_power_white_channels_; - for (int i = 2, j = 0; i < 12; i += 2, j++) { - data[i] = this->pwm_amounts_[j] >> 0x8; - data[i + 1] = this->pwm_amounts_[j] & 0xFF; - } - this->write_buffer_(data, 12); +void Sm10BitBase::loop() { + this->disable_loop(); + + const bool color_on = this->pwm_amounts_[0] > 0 || this->pwm_amounts_[1] > 0 || this->pwm_amounts_[2] > 0; + const bool white_on = this->pwm_amounts_[3] > 0 || this->pwm_amounts_[4] > 0; + uint8_t addr = SM10BIT_ADDR_START_5CH; + if (color_on && !white_on) { + addr = SM10BIT_ADDR_START_3CH; + } else if (white_on && !color_on) { + addr = SM10BIT_ADDR_START_2CH; } - this->update_ = false; + uint8_t data[12]{}; + data[0] = SM10BIT_MODEL_ID + addr; + // Only the max power of the channel groups being driven is sent + data[1] = (color_on ? this->max_power_color_channels_ << 4 : 0) | (white_on ? this->max_power_white_channels_ : 0); + for (size_t i = 0; i < this->pwm_amounts_.size(); i++) { + data[2 + i * 2] = this->pwm_amounts_[i] >> 8; + data[3 + i * 2] = this->pwm_amounts_[i] & 0xFF; + } + this->write_buffer_(data, sizeof(data)); + + if (!color_on && !white_on) { + // All channels are off, so put the chip to sleep + data[0] = SM10BIT_MODEL_ID + SM10BIT_ADDR_STANDBY; + this->write_buffer_(data, sizeof(data)); + } } void Sm10BitBase::write_channel(uint8_t channel, float state) { auto value = static_cast(state * 0x3FF); if (this->pwm_amounts_[channel] != value) { - this->update_ = true; - this->update_channel_ = channel; + this->pwm_amounts_[channel] = value; + this->enable_loop(); } - this->pwm_amounts_[channel] = value; } + void Sm10BitBase::write_bit_(bool value) { this->data_pin_->digital_write(value); delayMicroseconds(SM10BIT_DELAY); @@ -111,7 +97,7 @@ void Sm10BitBase::write_byte_(uint8_t data) { this->data_pin_->pin_mode(gpio::FLAG_OUTPUT); } -void Sm10BitBase::write_buffer_(uint8_t *buffer, uint8_t size) { +void Sm10BitBase::write_buffer_(const uint8_t *buffer, uint8_t size) { this->data_pin_->digital_write(false); delayMicroseconds(SM10BIT_DELAY); this->clock_pin_->digital_write(false); diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index 3b2d4bc435c..26ac48e116d 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -2,26 +2,22 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include +#include namespace esphome::sm10bit_base { class Sm10BitBase : public Component { public: - void set_model(uint8_t model_id) { model_id_ = model_id; } - void set_data_pin(GPIOPin *data_pin) { data_pin_ = data_pin; } - void set_clock_pin(GPIOPin *clock_pin) { clock_pin_ = clock_pin; } - void set_max_power_color_channels(uint8_t max_power_color_channels) { - max_power_color_channels_ = max_power_color_channels; - } - void set_max_power_white_channels(uint8_t max_power_white_channels) { - max_power_white_channels_ = max_power_white_channels; - } + Sm10BitBase(GPIOPin *data_pin, GPIOPin *clock_pin, uint8_t max_power_color_channels, uint8_t max_power_white_channels) + : data_pin_(data_pin), + clock_pin_(clock_pin), + max_power_color_channels_(max_power_color_channels), + max_power_white_channels_(max_power_white_channels) {} float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override; - void dump_config() override; + /// Send the new duty values, then sleep until write_channel() changes one. void loop() override; void write_channel(uint8_t channel, float state); @@ -29,16 +25,13 @@ class Sm10BitBase : public Component { protected: void write_bit_(bool value); void write_byte_(uint8_t data); - void write_buffer_(uint8_t *buffer, uint8_t size); + void write_buffer_(const uint8_t *buffer, uint8_t size); - GPIOPin *data_pin_; - GPIOPin *clock_pin_; - uint8_t model_id_; - uint8_t max_power_color_channels_{2}; - uint8_t max_power_white_channels_{4}; - uint8_t update_channel_; - std::vector pwm_amounts_; - bool update_{true}; + GPIOPin *const data_pin_; + GPIOPin *const clock_pin_; + std::array pwm_amounts_{}; + const uint8_t max_power_color_channels_; + const uint8_t max_power_white_channels_; }; } // namespace esphome::sm10bit_base diff --git a/esphome/components/sm2235/__init__.py b/esphome/components/sm2235/__init__.py index 803cd54be59..8a1796db5e1 100644 --- a/esphome/components/sm2235/__init__.py +++ b/esphome/components/sm2235/__init__.py @@ -20,5 +20,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = await sm10bit_base.register_sm10bit_base(config) - cg.add(var.set_model(0xC0)) + await sm10bit_base.register_sm10bit_base(config) diff --git a/esphome/components/sm2235/sm2235.cpp b/esphome/components/sm2235/sm2235.cpp index 3bd302047bc..e8330154e80 100644 --- a/esphome/components/sm2235/sm2235.cpp +++ b/esphome/components/sm2235/sm2235.cpp @@ -5,14 +5,6 @@ namespace esphome::sm2235 { ESPHOME_LOG_TAG(TAG, "sm2235"); -void SM2235::setup() { - this->data_pin_->setup(); - this->data_pin_->digital_write(true); - this->clock_pin_->setup(); - this->clock_pin_->digital_write(true); - this->pwm_amounts_.resize(5, 0); -} - void SM2235::dump_config() { ESP_LOGCONFIG(TAG, "SM2235:\n" diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index dbb51945f63..75d40d1fca5 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -1,16 +1,13 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sm10bit_base/sm10bit_base.h" -#include "esphome/core/hal.h" namespace esphome::sm2235 { class SM2235 final : public sm10bit_base::Sm10BitBase { public: - SM2235() = default; + using Sm10BitBase::Sm10BitBase; - void setup() override; void dump_config() override; }; diff --git a/esphome/components/sm2335/__init__.py b/esphome/components/sm2335/__init__.py index a9fdfcef391..34ea253c551 100644 --- a/esphome/components/sm2335/__init__.py +++ b/esphome/components/sm2335/__init__.py @@ -20,5 +20,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = await sm10bit_base.register_sm10bit_base(config) - cg.add(var.set_model(0xC0)) + await sm10bit_base.register_sm10bit_base(config) diff --git a/esphome/components/sm2335/sm2335.cpp b/esphome/components/sm2335/sm2335.cpp index 6e0d7b35887..cc6c889fdb7 100644 --- a/esphome/components/sm2335/sm2335.cpp +++ b/esphome/components/sm2335/sm2335.cpp @@ -5,14 +5,6 @@ namespace esphome::sm2335 { ESPHOME_LOG_TAG(TAG, "sm2335"); -void SM2335::setup() { - this->data_pin_->setup(); - this->data_pin_->digital_write(true); - this->clock_pin_->setup(); - this->clock_pin_->digital_write(true); - this->pwm_amounts_.resize(5, 0); -} - void SM2335::dump_config() { ESP_LOGCONFIG(TAG, "sm2335:\n" diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index 7c4f0269aa1..f173d4bc9fe 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -1,16 +1,13 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sm10bit_base/sm10bit_base.h" -#include "esphome/core/hal.h" namespace esphome::sm2335 { class SM2335 final : public sm10bit_base::Sm10BitBase { public: - SM2335() = default; + using Sm10BitBase::Sm10BitBase; - void setup() override; void dump_config() override; }; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 56e462d01f7..11a4ec3de1f 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -84,14 +84,10 @@ bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { } // publish sensor values - if (result->temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*result->temperature); - if (result->external_temperature.has_value() && this->external_temperature_sensor_ != nullptr) - this->external_temperature_sensor_->publish_state(*result->external_temperature); - if (result->humidity.has_value() && this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(*result->humidity); - if (result->battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*result->battery_level); + publish_optional(this->temperature_sensor_, result->temperature); + publish_optional(this->external_temperature_sensor_, result->external_temperature); + publish_optional(this->humidity_sensor_, result->humidity); + publish_optional(this->battery_level_sensor_, result->battery_level); success = true; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index fc9fc8aa086..ebf383b4ed5 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -2,6 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/i2c/i2c.h" #include @@ -17,27 +18,12 @@ static const uint8_t SYSCONFIG_ADDRESS = 0xA5; static const uint8_t PCONFIG_ADDRESS = 0xA6; static const uint8_t READ_COMMAND = 0x0A; -[[maybe_unused]] static const char *oversampling_to_str(XGZP68XXOversampling oversampling) { - switch (oversampling) { - case XGZP68XX_OVERSAMPLING_256X: - return "256x"; - case XGZP68XX_OVERSAMPLING_512X: - return "512x"; - case XGZP68XX_OVERSAMPLING_1024X: - return "1024x"; - case XGZP68XX_OVERSAMPLING_2048X: - return "2048x"; - case XGZP68XX_OVERSAMPLING_4096X: - return "4096x"; - case XGZP68XX_OVERSAMPLING_8192X: - return "8192x"; - case XGZP68XX_OVERSAMPLING_16384X: - return "16384x"; - case XGZP68XX_OVERSAMPLING_32768X: - return "32768x"; - default: - return "UNKNOWN"; - } +// Indexed by the XGZP68XXOversampling register value, which is not in size order +PROGMEM_STRING_TABLE(OversamplingStrings, "1024x", "2048x", "4096x", "8192x", "256x", "512x", "16384x", "32768x", + "UNKNOWN"); + +[[maybe_unused]] static const LogString *oversampling_to_str(XGZP68XXOversampling value) { + return OversamplingStrings::get_log_str(static_cast(value), OversamplingStrings::LAST_INDEX); } void XGZP68XXComponent::update() { @@ -48,7 +34,7 @@ void XGZP68XXComponent::update() { uint8_t newconfig = (oldconfig & 0xf8) | (this->pressure_oversampling_ & 0x7); this->write_register(PCONFIG_ADDRESS, &newconfig, 1); ESP_LOGD(TAG, "oversampling to %s: oldconfig = 0x%x newconfig = 0x%x", - oversampling_to_str(this->pressure_oversampling_), oldconfig, newconfig); + LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_)), oldconfig, newconfig); this->last_pressure_oversampling_ = this->pressure_oversampling_; } @@ -106,7 +92,7 @@ void XGZP68XXComponent::dump_config() { LOG_SENSOR(" ", "Temperature: ", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure: ", this->pressure_sensor_); if (this->pressure_sensor_ != nullptr) { - ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); + ESP_LOGCONFIG(TAG, " Oversampling: %s", LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_))); } LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index ac3cbca2f6d..d803cb6129f 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -15,12 +15,9 @@ void XiaomiCGPR1::dump_config() { } void XiaomiCGPR1::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.idle_time.has_value() && this->idle_time_sensor_ != nullptr) - this->idle_time_sensor_->publish_state(*res.idle_time); - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); - if (res.illuminance.has_value() && this->illuminance_sensor_ != nullptr) - this->illuminance_sensor_->publish_state(*res.illuminance); + publish_optional(this->idle_time_sensor_, res.idle_time); + publish_optional(this->battery_level_sensor_, res.battery_level); + publish_optional(this->illuminance_sensor_, res.illuminance); if (res.has_motion.has_value()) this->publish_state(*res.has_motion); } diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 31c47490c85..ff394c31f77 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -14,14 +14,10 @@ void XiaomiGCLS002::dump_config() { } void XiaomiGCLS002::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); - if (res.moisture.has_value() && this->moisture_sensor_ != nullptr) - this->moisture_sensor_->publish_state(*res.moisture); - if (res.conductivity.has_value() && this->conductivity_sensor_ != nullptr) - this->conductivity_sensor_->publish_state(*res.conductivity); - if (res.illuminance.has_value() && this->illuminance_sensor_ != nullptr) - this->illuminance_sensor_->publish_state(*res.illuminance); + publish_optional(this->temperature_sensor_, res.temperature); + publish_optional(this->moisture_sensor_, res.moisture); + publish_optional(this->conductivity_sensor_, res.conductivity); + publish_optional(this->illuminance_sensor_, res.illuminance); } } // namespace esphome::xiaomi_gcls002 diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 16bae640954..3dd17ee481e 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -15,16 +15,11 @@ void XiaomiHHCCJCY01::dump_config() { } void XiaomiHHCCJCY01::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); - if (res.moisture.has_value() && this->moisture_sensor_ != nullptr) - this->moisture_sensor_->publish_state(*res.moisture); - if (res.conductivity.has_value() && this->conductivity_sensor_ != nullptr) - this->conductivity_sensor_->publish_state(*res.conductivity); - if (res.illuminance.has_value() && this->illuminance_sensor_ != nullptr) - this->illuminance_sensor_->publish_state(*res.illuminance); - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); + publish_optional(this->temperature_sensor_, res.temperature); + publish_optional(this->moisture_sensor_, res.moisture); + publish_optional(this->conductivity_sensor_, res.conductivity); + publish_optional(this->illuminance_sensor_, res.illuminance); + publish_optional(this->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_hhccjcy01 diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index 4d4a24dc203..d7df1f2d317 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -12,10 +12,8 @@ void XiaomiHHCCPOT002::dump_config() { } void XiaomiHHCCPOT002::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.moisture.has_value() && this->moisture_sensor_ != nullptr) - this->moisture_sensor_->publish_state(*res.moisture); - if (res.conductivity.has_value() && this->conductivity_sensor_ != nullptr) - this->conductivity_sensor_->publish_state(*res.conductivity); + publish_optional(this->moisture_sensor_, res.moisture); + publish_optional(this->conductivity_sensor_, res.conductivity); } } // namespace esphome::xiaomi_hhccpot002 diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index 1b28de2a254..78e276b9716 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -14,14 +14,10 @@ void XiaomiJQJCY01YM::dump_config() { } void XiaomiJQJCY01YM::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); - if (res.humidity.has_value() && this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(*res.humidity); - if (res.formaldehyde.has_value() && this->formaldehyde_sensor_ != nullptr) - this->formaldehyde_sensor_->publish_state(*res.formaldehyde); - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); + publish_optional(this->temperature_sensor_, res.temperature); + publish_optional(this->humidity_sensor_, res.humidity); + publish_optional(this->formaldehyde_sensor_, res.formaldehyde); + publish_optional(this->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_jqjcy01ym diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp index 726e5dc0660..f79c3fc8acc 100644 --- a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -69,10 +69,8 @@ bool XiaomiMCCGQ02HL::parse_device(const ble_device_base::ESPBTDevice &device) { if (reading.open.has_value()) this->publish_state(*reading.open); - if (reading.light.has_value() && this->light_binary_sensor_ != nullptr) - this->light_binary_sensor_->publish_state(*reading.light); - if (reading.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*reading.battery_level); + publish_optional(this->light_binary_sensor_, reading.light); + publish_optional(this->battery_level_sensor_, reading.battery_level); success = true; } return success; diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 9da84410fd5..b403e557f53 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -34,8 +34,7 @@ bool XiaomiMiscale::parse_device(const ble_device_base::ESPBTDevice &device) { if (!report_results_(res, addr_str)) continue; - if (res->weight.has_value() && this->weight_sensor_ != nullptr) - this->weight_sensor_->publish_state(*res->weight); + publish_optional(this->weight_sensor_, res->weight); if (this->impedance_sensor_ != nullptr) { if (res->version == 1) { diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index 5d74eba582f..48625b7abf8 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -16,14 +16,10 @@ void XiaomiMJYD02YLA::dump_config() { } void XiaomiMJYD02YLA::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.idle_time.has_value() && this->idle_time_sensor_ != nullptr) - this->idle_time_sensor_->publish_state(*res.idle_time); - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); - if (res.illuminance.has_value() && this->illuminance_sensor_ != nullptr) - this->illuminance_sensor_->publish_state(*res.illuminance); - if (res.is_light.has_value() && this->light_binary_sensor_ != nullptr) - this->light_binary_sensor_->publish_state(*res.is_light); + publish_optional(this->idle_time_sensor_, res.idle_time); + publish_optional(this->battery_level_sensor_, res.battery_level); + publish_optional(this->illuminance_sensor_, res.illuminance); + publish_optional(this->light_binary_sensor_, res.is_light); if (res.has_motion.has_value()) this->publish_state(*res.has_motion); } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index 9ac43118ab9..586465b0b31 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -28,8 +28,7 @@ void XiaomiRTCGQ02LM::publish_result(const xiaomi_ble::XiaomiParseResult &res) { this->set_timeout(MOTION_TIMEOUT_ID, this->motion_timeout_, [this]() { this->motion_binary_sensor_->publish_state(false); }); } - if (res.is_light.has_value() && this->light_binary_sensor_ != nullptr) - this->light_binary_sensor_->publish_state(*res.is_light); + publish_optional(this->light_binary_sensor_, res.is_light); if (res.button_press.has_value() && this->button_binary_sensor_ != nullptr) { this->button_binary_sensor_->publish_state(*res.button_press); this->set_timeout(BUTTON_TIMEOUT_ID, this->button_timeout_, @@ -37,8 +36,7 @@ void XiaomiRTCGQ02LM::publish_result(const xiaomi_ble::XiaomiParseResult &res) { } #endif #ifdef USE_SENSOR - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); + publish_optional(this->battery_level_sensor_, res.battery_level); #endif } diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index d33b318ce9d..d6365cbe349 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -16,10 +16,8 @@ void XiaomiWX08ZM::publish_result(const xiaomi_ble::XiaomiParseResult &res) { if (res.is_active.has_value()) { this->publish_state(*res.is_active); } - if (res.tablet.has_value() && this->tablet_sensor_ != nullptr) - this->tablet_sensor_->publish_state(*res.tablet); - if (res.battery_level.has_value() && this->battery_level_sensor_ != nullptr) - this->battery_level_sensor_->publish_state(*res.battery_level); + publish_optional(this->tablet_sensor_, res.tablet); + publish_optional(this->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_wx08zm diff --git a/tests/component_tests/ld6004/__init__.py b/tests/component_tests/ld6004/__init__.py new file mode 100644 index 00000000000..5be549c7292 --- /dev/null +++ b/tests/component_tests/ld6004/__init__.py @@ -0,0 +1 @@ +"""Tests for the LD6004 radar component.""" diff --git a/tests/component_tests/ld6004/config/base.yaml b/tests/component_tests/ld6004/config/base.yaml new file mode 100644 index 00000000000..9f6ae3c106d --- /dev/null +++ b/tests/component_tests/ld6004/config/base.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 115200 + +ld6004: + id: radar + +binary_sensor: + - platform: ld6004 + target: + name: Presence diff --git a/tests/component_tests/ld6004/config/binary_sensor_only.yaml b/tests/component_tests/ld6004/config/binary_sensor_only.yaml new file mode 100644 index 00000000000..b01f28438e3 --- /dev/null +++ b/tests/component_tests/ld6004/config/binary_sensor_only.yaml @@ -0,0 +1,2 @@ +packages: + base: !include base.yaml diff --git a/tests/component_tests/ld6004/config/detection_area_sensor.yaml b/tests/component_tests/ld6004/config/detection_area_sensor.yaml new file mode 100644 index 00000000000..0554f2de78a --- /dev/null +++ b/tests/component_tests/ld6004/config/detection_area_sensor.yaml @@ -0,0 +1,8 @@ +packages: + base: !include base.yaml + +sensor: + - platform: ld6004 + detection_area_0: + x_min: + name: Detection X Min diff --git a/tests/component_tests/ld6004/config/dwell_area_sensor.yaml b/tests/component_tests/ld6004/config/dwell_area_sensor.yaml new file mode 100644 index 00000000000..24289905c57 --- /dev/null +++ b/tests/component_tests/ld6004/config/dwell_area_sensor.yaml @@ -0,0 +1,8 @@ +packages: + base: !include base.yaml + +sensor: + - platform: ld6004 + dwell_area_1: + x_min: + name: Dwell X Min diff --git a/tests/component_tests/ld6004/test_area_kinds.py b/tests/component_tests/ld6004/test_area_kinds.py new file mode 100644 index 00000000000..0874387b6f4 --- /dev/null +++ b/tests/component_tests/ld6004/test_area_kinds.py @@ -0,0 +1,28 @@ +"""The LD6004 sizes its dwell areas only when an entity uses them.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + + +@pytest.mark.parametrize( + ("config_file", "area_kinds"), + [ + ("binary_sensor_only.yaml", "2"), + ("detection_area_sensor.yaml", "2"), + ("dwell_area_sensor.yaml", "3"), + ], +) +def test_area_kinds( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + area_kinds: str, +) -> None: + generate_main(component_config_path(config_file)) + defines = {define.name: str(define.value) for define in CORE.defines} + assert defines["LD600X_AREA_KINDS"] == area_kinds + assert defines["LD600X_MAX_TARGETS"] == "3" diff --git a/tests/component_tests/ld6004/test_hub.py b/tests/component_tests/ld6004/test_hub.py new file mode 100644 index 00000000000..ddabba8d40a --- /dev/null +++ b/tests/component_tests/ld6004/test_hub.py @@ -0,0 +1,42 @@ +"""Validate the LD6004 hub and binary sensor configuration limits.""" + +import pytest + +from esphome.components.ld6004 import CONFIG_SCHEMA, LD6004Component +from esphome.components.ld6004.binary_sensor import ( + CONFIG_SCHEMA as BINARY_SENSOR_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def test_minimal_hub(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config: ConfigType = CONFIG_SCHEMA({}) + assert config[CONF_ID].type == LD6004Component + + +@pytest.mark.parametrize( + ("key", "value"), + [("out_pin", 4), ("stale_timeout", "1s"), ("wakeup_pin", 4)], +) +def test_unsupported_hub_option( + set_core_config: SetCoreConfigCallable, key: str, value: int | str +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match=key): + CONFIG_SCHEMA({key: value}) + + +def test_target_3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config: ConfigType = BINARY_SENSOR_SCHEMA({"target_3": {"name": "Target 3"}}) + assert config["target_3"]["name"] == "Target 3" + + +def test_target_4_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="target_4"): + BINARY_SENSOR_SCHEMA({"target_4": {"name": "Target 4"}}) diff --git a/tests/component_tests/ld6004/test_sensor.py b/tests/component_tests/ld6004/test_sensor.py new file mode 100644 index 00000000000..35cc48538e6 --- /dev/null +++ b/tests/component_tests/ld6004/test_sensor.py @@ -0,0 +1,48 @@ +"""Validate LD6004 sensor target and area limits.""" + +import pytest + +from esphome.components.ld6004 import LD6004Component +from esphome.components.ld6004.sensor import CONFIG_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize( + ("key", "fields"), + [ + ("target_3", ("x", "y", "z", "doppler_index", "cluster_id")), + ("dwell_area_3", ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")), + ], +) +def test_model_sensor_keys( + set_core_config: SetCoreConfigCallable, key: str, fields: tuple[str, ...] +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config: ConfigType = CONFIG_SCHEMA( + {key: {field: {"name": field} for field in fields}} + ) + assert config["ld6004_id"].type == LD6004Component + assert set(config[key]) == set(fields) + for field in fields: + assert config[key][field]["name"] == field + + +@pytest.mark.parametrize( + "key", + [ + "target_0", + "target_4", + "interference_area_4", + "detection_area_4", + "dwell_area_4", + ], +) +def test_out_of_range_sensor_keys( + set_core_config: SetCoreConfigCallable, key: str +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match=key): + CONFIG_SCHEMA({key: {"x": {"name": "Invalid sensor"}}}) diff --git a/tests/component_tests/ld600x/test_model_sizes.py b/tests/component_tests/ld600x/test_model_sizes.py index 1a61455e679..3eecf038140 100644 --- a/tests/component_tests/ld600x/test_model_sizes.py +++ b/tests/component_tests/ld600x/test_model_sizes.py @@ -23,7 +23,7 @@ def test_requests_keep_the_largest_count() -> None: assert defines["LD600X_MAX_TARGETS"] == "3" -def test_two_area_kinds_by_default() -> None: +def test_one_request_sets_its_area_kind_count() -> None: request_model_sizes(MockObj("hub"), area_kinds=2) CORE.flush_tasks() assert _defines()["LD600X_AREA_KINDS"] == "2" diff --git a/tests/components/gcja5/sensor/gcja5_test.cpp b/tests/components/gcja5/sensor/gcja5_test.cpp index 37edee7448c..a04255ce9a6 100644 --- a/tests/components/gcja5/sensor/gcja5_test.cpp +++ b/tests/components/gcja5/sensor/gcja5_test.cpp @@ -72,6 +72,7 @@ TEST_F(GCJA5Test, BadChecksumIsDropped) { this->feed_(frame(11, 21)); ASSERT_EQ(this->pm_2_5_published_.size(), 1u); EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 11); + EXPECT_FALSE(this->sut_.status_has_warning()); } TEST_F(GCJA5Test, BadEndByteIsDropped) { @@ -81,14 +82,14 @@ TEST_F(GCJA5Test, BadEndByteIsDropped) { EXPECT_TRUE(this->pm_2_5_published_.empty()); } -// Pins current behaviour: once one good frame was seen, the values of a later bad frame are published -TEST_F(GCJA5Test, BadFrameAfterGoodFrameIsPublished) { +TEST_F(GCJA5Test, BadFrameAfterGoodFrameIsNotPublished) { this->feed_(frame(10, 20)); auto bad = frame(99, 98); bad[30] ^= 1; this->feed_(bad); - ASSERT_EQ(this->pm_2_5_published_.size(), 2u); - EXPECT_FLOAT_EQ(this->pm_2_5_published_[1], 99); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 10); + EXPECT_TRUE(this->sut_.status_has_warning()); } TEST_F(GCJA5Test, SplitReadsAreJoined) { diff --git a/tests/components/ld6002b/common.h b/tests/components/ld6002b/common.h index 139bc81bffa..b5a39b83f15 100644 --- a/tests/components/ld6002b/common.h +++ b/tests/components/ld6002b/common.h @@ -148,8 +148,8 @@ inline void apply_select(select::Select *entity, size_t index) { test_hub()->set_select_value(entity, index); } inline void apply_switch(switch_::Switch *entity, bool state) { - entity->publish_state(state); - test_hub()->set_switch_state(entity, state); + if (test_hub()->set_switch_state(entity, state)) + entity->publish_state(state); } using TestNumber = number::ApplyNumber; diff --git a/tests/components/ld6002b/switch/ld6002b_switch_test.cpp b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp index 95ad89599c0..05f53443a67 100644 --- a/tests/components/ld6002b/switch/ld6002b_switch_test.cpp +++ b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp @@ -16,6 +16,13 @@ TEST_F(LD6002BTest, EmptyLowPowerReportIsIgnored) { EXPECT_TRUE(this->low_power_.state); } +TEST_F(LD6002BTest, LowPowerSwitchKeepsStateWhenQueueFull) { + for (int i = 0; i < 32; i++) + this->sensitivity_.make_call().set_index(0).perform(); + this->low_power_.turn_on(); + EXPECT_FALSE(this->low_power_.state); +} + } // namespace esphome::ld6002b::testing #endif // USE_HOST diff --git a/tests/components/ld6004/binary_sensor/ld6004_binary_sensor_test.cpp b/tests/components/ld6004/binary_sensor/ld6004_binary_sensor_test.cpp new file mode 100644 index 00000000000..4667bd3604b --- /dev/null +++ b/tests/components/ld6004/binary_sensor/ld6004_binary_sensor_test.cpp @@ -0,0 +1,96 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6004::testing { + +TEST_F(LD6004Test, TargetReportSetsPresence) { + this->feed_(target_report(1)); + EXPECT_TRUE(this->presence_.state); + EXPECT_TRUE(this->target_presence_[0].state); + EXPECT_FALSE(this->target_presence_[1].state); + + this->feed_(target_report(0)); + EXPECT_FALSE(this->presence_.state); + EXPECT_FALSE(this->target_presence_[0].state); +} + +TEST_F(LD6004Test, AreaPresenceReportSetsAreaAndPresence) { + this->feed_(area_presence({0, 0, 1, 0})); + EXPECT_FALSE(this->area_presence_[0].state); + EXPECT_TRUE(this->area_presence_[2].state); + EXPECT_TRUE(this->presence_.state); +} + +TEST_F(LD6004Test, IdleWorkStatusClearsAreaPresence) { + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_STATUS, {0x00})); + EXPECT_FALSE(this->area_presence_[1].state); + EXPECT_FALSE(this->presence_.state); +} + +TEST_F(LD6004Test, IdleWorkStatusKeepsAreaPresenceWhileTracking) { + this->feed_(target_report(1)); + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_STATUS, {0x00})); + EXPECT_TRUE(this->area_presence_[1].state); + EXPECT_TRUE(this->presence_.state); +} + +TEST_F(LD6004Test, BusyWorkStatusKeepsAreaPresence) { + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_STATUS, {0x01})); + EXPECT_TRUE(this->area_presence_[1].state); +} + +class LD6004WorkModeTest : public LD6004Test, public ::testing::WithParamInterface {}; + +// Only the two radar-off modes stop detection; the module sends the mode as one byte or as a uint32 +TEST_P(LD6004WorkModeTest, RadarOffModesClearPresence) { + const uint8_t mode = GetParam(); + const bool radar_off = mode == WORK_MODE_RADAR_OFF_P20_HIGH || mode == WORK_MODE_RADAR_OFF_P20_LOW; + for (bool wide : {false, true}) { + this->feed_(work_mode(WORK_MODE_NORMAL)); + this->feed_(target_report(2)); + this->feed_(area_presence({1, 0, 0, 1})); + this->feed_(wide ? tf_frame(TYPE_REPORT_WORK_MODE, u32_payload(mode)) : work_mode(mode)); + EXPECT_EQ(this->presence_.state, !radar_off); + EXPECT_EQ(this->target_presence_[0].state, !radar_off); + EXPECT_EQ(this->target_presence_[1].state, !radar_off); + EXPECT_EQ(this->area_presence_[0].state, !radar_off); + EXPECT_EQ(this->area_presence_[3].state, !radar_off); + // Cleared to off, not to unknown + EXPECT_TRUE(this->presence_.has_state()); + EXPECT_TRUE(this->target_presence_[0].has_state()); + EXPECT_TRUE(this->area_presence_[0].has_state()); + } +} + +INSTANTIATE_TEST_SUITE_P(Modes, LD6004WorkModeTest, + ::testing::Values(WORK_MODE_NORMAL, WORK_MODE_LOW_POWER, WORK_MODE_RADAR_OFF_P20_HIGH, + WORK_MODE_RADAR_OFF_P20_LOW, WORK_MODE_HIGH_REFLECTIVITY)); + +TEST_F(LD6004Test, TrailingReportsDoNotRestorePresenceWhileRadarIsOff) { + this->feed_(target_report(1)); + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(work_mode(WORK_MODE_RADAR_OFF_P20_LOW)); + this->feed_(target_report(1)); + this->feed_(area_presence({0, 1, 0, 0})); + EXPECT_FALSE(this->presence_.state); + EXPECT_FALSE(this->target_presence_[0].state); + EXPECT_FALSE(this->area_presence_[1].state); +} + +TEST_F(LD6004Test, ReportsCountAgainOnceRadarIsBackOn) { + this->feed_(work_mode(WORK_MODE_RADAR_OFF_P20_HIGH)); + this->feed_(work_mode(WORK_MODE_HIGH_REFLECTIVITY)); + this->feed_(target_report(1)); + EXPECT_TRUE(this->target_presence_[0].state); + this->feed_(target_report(0)); + this->feed_(area_presence({0, 0, 1, 0})); + EXPECT_TRUE(this->area_presence_[2].state); + EXPECT_TRUE(this->presence_.state); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/common.h b/tests/components/ld6004/common.h new file mode 100644 index 00000000000..e1e9bf292fb --- /dev/null +++ b/tests/components/ld6004/common.h @@ -0,0 +1,172 @@ +#pragma once + +#ifdef USE_HOST + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/host/preferences.h" +#include "esphome/components/ld6004/ld6004.h" +#include "esphome/components/ld600x/ld600x_frame.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::ld6004::testing { + +using ld600x::AREA_COUNT; +static constexpr uint8_t MAX_TARGETS = LD600X_MAX_TARGETS; + +static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A12; +static constexpr uint16_t TYPE_REPORT_WORK_STATUS = 0x0A14; + +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +// Every frame this component sends has the top bit of its id set. +static constexpr uint16_t FIRST_COMMAND_ID = 0x8000; + +// The 0x0A12 values, in the order of the work_mode select options. +enum WorkMode : uint8_t { + WORK_MODE_NORMAL, + WORK_MODE_LOW_POWER, + WORK_MODE_RADAR_OFF_P20_HIGH, + WORK_MODE_RADAR_OFF_P20_LOW, + WORK_MODE_HIGH_REFLECTIVITY, + WORK_MODE_COUNT, +}; + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + std::vector tx; + + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t *data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +inline void put_u32(std::vector &out, uint32_t value) { + for (int i = 0; i < 4; i++) + out.push_back(static_cast(value >> (8 * i))); +} + +inline void put_f32(std::vector &out, float value) { put_u32(out, bit_cast(value)); } + +inline std::vector tf_frame(uint16_t type, const std::vector &payload, uint16_t id = 0x0001) { + std::vector out(ld600x::TF_FRAME_OVERHEAD + payload.size()); + out.resize(ld600x::encode_frame(id, type, payload.empty() ? nullptr : payload.data(), + static_cast(payload.size()), out.data())); + return out; +} + +// An ACK is the command's own type with an empty payload. +inline std::vector ack(uint16_t type) { return tf_frame(type, {}); } + +inline std::vector u32_payload(uint32_t value) { + std::vector p; + put_u32(p, value); + return p; +} + +// What the component is expected to put on the wire for a command +inline std::vector command(uint16_t id, uint16_t type, const std::vector &payload) { + return tf_frame(type, payload, id); +} + +inline std::vector work_mode(uint8_t mode) { return tf_frame(TYPE_REPORT_WORK_MODE, {mode}); } + +// One target at (1, 1, 1) with the given cluster id, or an empty report when count is 0. +inline std::vector target_report(uint8_t count) { + std::vector p; + put_u32(p, count); + for (uint8_t i = 0; i < count; i++) { + put_f32(p, 1.0f); + put_f32(p, 1.0f); + put_f32(p, 1.0f); + put_u32(p, 0); + put_u32(p, 10 + i); + } + return tf_frame(TYPE_REPORT_TARGET, p); +} + +inline std::vector area_presence(const std::array &states) { + std::vector p; + for (uint32_t s : states) + put_u32(p, s); + return tf_frame(TYPE_REPORT_AREA_PRESENCE, p); +} + +/// A hub with the presence binary sensors, set up as codegen does and past its boot timeout. +class LD6004Test : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = std::filesystem::temp_directory_path() / "esphome_ld6004_test"; + std::filesystem::remove_all(this->temp_dir_); + std::filesystem::create_directories(this->temp_dir_); + setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); + host::host_preferences = &this->prefs_; + global_preferences = &this->prefs_; + + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_presence_binary_sensor(&this->presence_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) + this->sut_.set_target_presence_binary_sensor(i, &this->target_presence_[i]); + for (uint8_t i = 0; i < AREA_COUNT; i++) + this->sut_.set_area_presence_binary_sensor(i, &this->area_presence_[i]); + + this->sut_.setup(); + // Past setup()'s 100 ms boot timeout, which starts the streams and queues the queries + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + App.scheduler.call(millis()); + } + + void TearDown() override { + host::setup_preferences(); + unsetenv("ESPHOME_PREFDIR"); + std::filesystem::remove_all(this->temp_dir_); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + host::HostPreferences prefs_; + std::filesystem::path temp_dir_; + MockUARTComponent uart_; + LD6004Component sut_; + + binary_sensor::BinarySensor presence_; + binary_sensor::BinarySensor target_presence_[MAX_TARGETS]; + binary_sensor::BinarySensor area_presence_[AREA_COUNT]; +}; + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml new file mode 100644 index 00000000000..cfbef8d323b --- /dev/null +++ b/tests/components/ld6004/common.yaml @@ -0,0 +1,83 @@ +ld6004: + id: ld6004_radar + +binary_sensor: + - platform: ld6004 + ld6004_id: ld6004_radar + target: + name: LD6004 Presence + target_1: + name: LD6004 Target 1 + target_3: + name: LD6004 Target 3 + detection_area_0: + name: LD6004 Detection Area 0 + +sensor: + - platform: ld6004 + ld6004_id: ld6004_radar + target_count: + name: LD6004 Target Count + point_count: + name: LD6004 Point Count + target_1: + x: + name: LD6004 Target-1 X + y: + name: LD6004 Target-1 Y + z: + name: LD6004 Target-1 Z + doppler_index: + name: LD6004 Target-1 Dop + cluster_id: + name: LD6004 Target-1 Cluster + target_3: + x: + name: LD6004 Target-3 X + y: + name: LD6004 Target-3 Y + z: + name: LD6004 Target-3 Z + doppler_index: + name: LD6004 Target-3 Dop + cluster_id: + name: LD6004 Target-3 Cluster + interference_area_0: + x_min: + name: LD6004 Interference-0 X Min + x_max: + name: LD6004 Interference-0 X Max + y_min: + name: LD6004 Interference-0 Y Min + y_max: + name: LD6004 Interference-0 Y Max + z_min: + name: LD6004 Interference-0 Z Min + z_max: + name: LD6004 Interference-0 Z Max + detection_area_0: + x_min: + name: LD6004 Detection-0 X Min + x_max: + name: LD6004 Detection-0 X Max + y_min: + name: LD6004 Detection-0 Y Min + y_max: + name: LD6004 Detection-0 Y Max + z_min: + name: LD6004 Detection-0 Z Min + z_max: + name: LD6004 Detection-0 Z Max + dwell_area_3: + x_min: + name: LD6004 Dwell-3 X Min + x_max: + name: LD6004 Dwell-3 X Max + y_min: + name: LD6004 Dwell-3 Y Min + y_max: + name: LD6004 Dwell-3 Y Max + z_min: + name: LD6004 Dwell-3 Z Min + z_max: + name: LD6004 Dwell-3 Z Max diff --git a/tests/components/ld6004/ld6004_uart_test.cpp b/tests/components/ld6004/ld6004_uart_test.cpp new file mode 100644 index 00000000000..33fc8a311d0 --- /dev/null +++ b/tests/components/ld6004/ld6004_uart_test.cpp @@ -0,0 +1,47 @@ +#ifdef USE_HOST +#include "common.h" + +namespace esphome::ld6004::testing { + +TEST_F(LD6004Test, BootStartsTargetStreamFirst) { + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +// With only presence configured, boot stops the point cloud and sends no model query +TEST_F(LD6004Test, BootStopsPointCloudThenGoesQuiet) { + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID + 1, TYPE_CONTROL, u32_payload(CMD_POINT_CLOUD_OFF))); + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(LD6004Test, ReportsSendNothing) { + this->uart_.tx.clear(); + this->feed_(work_mode(WORK_MODE_RADAR_OFF_P20_HIGH)); + this->feed_(tf_frame(TYPE_REPORT_WORK_STATUS, {0x00})); + this->feed_(target_report(1)); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// A malformed model report is claimed and dropped, and the next frame still parses +TEST_F(LD6004Test, MalformedWorkModeReportIsIgnored) { + this->feed_(target_report(1)); + this->feed_(work_mode(WORK_MODE_COUNT)); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {WORK_MODE_RADAR_OFF_P20_HIGH, 0x00})); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {})); + EXPECT_TRUE(this->presence_.state); + this->feed_(target_report(0)); + EXPECT_FALSE(this->presence_.state); +} + +TEST_F(LD6004Test, EmptyWorkStatusReportIsIgnored) { + this->feed_(area_presence({1, 0, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_STATUS, {})); + EXPECT_TRUE(this->area_presence_[0].state); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/sensor/ld6004_sensor_test.cpp b/tests/components/ld6004/sensor/ld6004_sensor_test.cpp new file mode 100644 index 00000000000..a44b5b0f8e5 --- /dev/null +++ b/tests/components/ld6004/sensor/ld6004_sensor_test.cpp @@ -0,0 +1,41 @@ +#ifdef USE_HOST +#include "../common.h" + +#include "esphome/components/sensor/sensor.h" + +namespace esphome::ld6004::testing { + +namespace { + +constexpr uint16_t TYPE_REPORT_DWELL_AREAS = 0x0A16; + +} // namespace + +// A 0x0A16 report lands on the dwell areas only, not on the interference or detection ones +TEST_F(LD6004Test, DwellAreaReportPublishesDwellBounds) { + sensor::Sensor dwell[ld600x::AREA_AXIS_COUNT]; + sensor::Sensor interference_x_min; + sensor::Sensor detection_x_min; + for (uint8_t axis = 0; axis < ld600x::AREA_AXIS_COUNT; axis++) + this->sut_.set_area_sensor(ld600x::AREA_KIND_DWELL, 3, axis, &dwell[axis]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 3, ld600x::AREA_AXIS_X_MIN, &interference_x_min); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 3, ld600x::AREA_AXIS_X_MIN, &detection_x_min); + + // x_min, x_max, y_min, y_max, z_min, z_max for area 3; the other areas are zero + const float bounds[ld600x::AREA_AXIS_COUNT] = {-1.5f, 1.25f, 0.5f, 3.75f, -0.25f, 2.5f}; + std::vector p; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + for (float v : bounds) + put_f32(p, i == 3 ? v : 0.0f); + } + this->feed_(tf_frame(TYPE_REPORT_DWELL_AREAS, p)); + + for (uint8_t axis = 0; axis < ld600x::AREA_AXIS_COUNT; axis++) + EXPECT_FLOAT_EQ(dwell[axis].state, bounds[axis]) << "axis " << int(axis); + EXPECT_FALSE(interference_x_min.has_state()); + EXPECT_FALSE(detection_x_min.has_state()); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/test.esp32-idf.yaml b/tests/components/ld6004/test.esp32-idf.yaml new file mode 100644 index 00000000000..60028359912 --- /dev/null +++ b/tests/components/ld6004/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld6004: !include common.yaml diff --git a/tests/components/ld6004/test.esp8266-ard.yaml b/tests/components/ld6004/test.esp8266-ard.yaml new file mode 100644 index 00000000000..8f1b83befde --- /dev/null +++ b/tests/components/ld6004/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld6004: !include common.yaml diff --git a/tests/components/ld6004/test.rp2040-ard.yaml b/tests/components/ld6004/test.rp2040-ard.yaml new file mode 100644 index 00000000000..5545e2d1c0c --- /dev/null +++ b/tests/components/ld6004/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld6004: !include common.yaml diff --git a/tests/integration/fixtures/binary_sensor_delay_filters.yaml b/tests/integration/fixtures/binary_sensor_delay_filters.yaml new file mode 100644 index 00000000000..53840965772 --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_delay_filters.yaml @@ -0,0 +1,160 @@ +esphome: + name: test-binary-delay-filters + +host: +api: +logger: + level: DEBUG + +binary_sensor: + - platform: template + name: "Source" + id: source + trigger_on_initial_state: true + on_state: + - logger.log: + format: "SRC %s @%u" + args: ['x ? "ON" : "OFF"', 'millis()'] + + - platform: template + id: x + + - platform: copy + source_id: source + id: on_off_single + internal: true + trigger_on_initial_state: true + filters: + - delayed_on_off: 100ms + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"on_off_single"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: on_off_split + internal: true + trigger_on_initial_state: true + filters: + - delayed_on_off: + time_on: 100ms + time_off: 300ms + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"on_off_split"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: on_off_lambda + internal: true + trigger_on_initial_state: true + filters: + - delayed_on_off: + time_on: !lambda return 100; + time_off: !lambda |- + uint32_t delay = 300; + return delay; + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"on_off_lambda"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: on_off_id_x + internal: true + trigger_on_initial_state: true + filters: + - delayed_on_off: !lambda 'return id(x).state ? 100 : 300;' + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"on_off_id_x"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: delayed_on + internal: true + trigger_on_initial_state: true + filters: + - delayed_on: 100ms + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"delayed_on"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: delayed_on_lambda + internal: true + trigger_on_initial_state: true + filters: + - delayed_on: !lambda return 100; + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"delayed_on_lambda"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: delayed_off + internal: true + trigger_on_initial_state: true + filters: + - delayed_off: 100ms + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"delayed_off"', 'x ? "ON" : "OFF"', 'millis()'] + + - platform: copy + source_id: source + id: lambda_filter + internal: true + trigger_on_initial_state: true + filters: + - lambda: |- + static int count = 0; + if (++count == 2) + return {}; + return !x; + on_state: + - logger.log: + format: "OUT %s=%s @%u" + args: ['"lambda_filter"', 'x ? "ON" : "OFF"', 'millis()'] + +button: + - platform: template + name: "Run Filters" + id: run_filters + on_press: + - binary_sensor.template.publish: + id: x + state: ON + - binary_sensor.template.publish: + id: source + state: ON + - delay: 30ms + - binary_sensor.template.publish: + id: source + state: OFF + - delay: 30ms + - binary_sensor.template.publish: + id: source + state: ON + - delay: 500ms + - binary_sensor.template.publish: + id: source + state: OFF + - delay: 150ms + - binary_sensor.template.publish: + id: source + state: ON + - delay: 500ms + - binary_sensor.template.publish: + id: source + state: OFF + - delay: 500ms + - logger.log: "FILTERS DONE" diff --git a/tests/integration/fixtures/sensor_filters_apply.yaml b/tests/integration/fixtures/sensor_filters_apply.yaml new file mode 100644 index 00000000000..9a5c8dd64bd --- /dev/null +++ b/tests/integration/fixtures/sensor_filters_apply.yaml @@ -0,0 +1,274 @@ +esphome: + name: test-sensor-filters-apply + +host: +api: +logger: + level: DEBUG + +sensor: + - platform: template + name: "Source" + id: source + update_interval: never + + - platform: template + id: x + update_interval: never + + - platform: copy + source_id: source + id: offset_const + internal: true + filters: + - offset: 2.5 + on_value: + - logger.log: + format: "OUT offset_const=%.9g" + args: [x] + + - platform: copy + source_id: source + id: offset_lambda + internal: true + filters: + - offset: !lambda |- + float base = 1.0f; + return base + 0.25f; + on_value: + - logger.log: + format: "OUT offset_lambda=%.9g" + args: [x] + + - platform: copy + source_id: source + id: offset_id_x + internal: true + filters: + - offset: !lambda return id(x).state; + on_value: + - logger.log: + format: "OUT offset_id_x=%.9g" + args: [x] + + - platform: copy + source_id: source + id: multiply_const + internal: true + filters: + - multiply: 1.5 + on_value: + - logger.log: + format: "OUT multiply_const=%.9g" + args: [x] + + - platform: copy + source_id: source + id: multiply_lambda + internal: true + filters: + - multiply: !lambda return 1.0f + 1.0f; + on_value: + - logger.log: + format: "OUT multiply_lambda=%.9g" + args: [x] + + - platform: copy + source_id: source + id: clamp_min + internal: true + filters: + - clamp: + min_value: 0 + on_value: + - logger.log: + format: "OUT clamp_min=%.9g" + args: [x] + + - platform: copy + source_id: source + id: clamp_max + internal: true + filters: + - clamp: + max_value: 10 + on_value: + - logger.log: + format: "OUT clamp_max=%.9g" + args: [x] + + - platform: copy + source_id: source + id: clamp_both + internal: true + filters: + - clamp: + min_value: 0 + max_value: 10 + on_value: + - logger.log: + format: "OUT clamp_both=%.9g" + args: [x] + + - platform: copy + source_id: source + id: clamp_ignore + internal: true + filters: + - clamp: + min_value: 0 + max_value: 10 + ignore_out_of_range: true + on_value: + - logger.log: + format: "OUT clamp_ignore=%.9g" + args: [x] + + - platform: copy + source_id: source + id: ntc_resistance + internal: true + filters: + - to_ntc_resistance: + calibration: + b_constant: 3950 + reference_temperature: 25°C + reference_resistance: 10kOhm + on_value: + - logger.log: + format: "OUT ntc_resistance=%.9g" + args: [x] + + - platform: copy + source_id: source + id: ntc_resistance_abc + internal: true + filters: + - to_ntc_resistance: + calibration: + a: 0.0011 + b: 0.00023 + c: 0.0000001 + on_value: + - logger.log: + format: "OUT ntc_resistance_abc=%.9g" + args: [x] + + - platform: copy + source_id: source + id: ntc_temperature + internal: true + filters: + - multiply: 1000 + - to_ntc_temperature: + calibration: + b_constant: 3950 + reference_temperature: 25°C + reference_resistance: 10kOhm + on_value: + - logger.log: + format: "OUT ntc_temperature=%.9g" + args: [x] + + - platform: copy + source_id: source + id: ntc_temperature_abc + internal: true + filters: + - multiply: 1000 + - to_ntc_temperature: + calibration: + a: 0.0011 + b: 0.00023 + c: 0.0000001 + on_value: + - logger.log: + format: "OUT ntc_temperature_abc=%.9g" + args: [x] + + - platform: copy + source_id: source + id: calibrate_least_squares + internal: true + filters: + - calibrate_linear: + - 0.0 -> 0.0 + - 10.0 -> 10.5 + - 20.0 -> 19.0 + on_value: + - logger.log: + format: "OUT calibrate_least_squares=%.9g" + args: [x] + + - platform: copy + source_id: source + id: calibrate_exact + internal: true + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + on_value: + - logger.log: + format: "OUT calibrate_exact=%.9g" + args: [x] + + - platform: copy + source_id: source + id: lambda_filter + internal: true + filters: + - lambda: |- + if (x > 100.0f) + return {}; + return x * 2.0f; + on_value: + - logger.log: + format: "OUT lambda_filter=%.9g" + args: [x] + +text_sensor: + - platform: template + id: text_source + update_interval: never + + - platform: copy + source_id: text_source + id: text_lambda_replace + internal: true + filters: + - lambda: return {"[" + x + "]"}; + on_value: + - logger.log: + format: "TXT text_lambda_replace=%s" + args: [x.c_str()] + + - platform: copy + source_id: text_source + id: text_lambda_drop + internal: true + filters: + - lambda: |- + if (x == "skip") + return {}; + return x; + on_value: + - logger.log: + format: "TXT text_lambda_drop=%s" + args: [x.c_str()] + +button: + - platform: template + name: "Run Filters" + id: run_filters + on_press: + - lambda: |- + id(x).publish_state(0.75f); + for (float value : {-10.0f, 0.0f, 0.5f, 2.25f, 12.345f, 50.0f, 150.0f, NAN, INFINITY}) + id(source).publish_state(value); + for (const char *value : {"a", "skip", "b"}) + id(text_source).publish_state(value); + ESP_LOGI("test", "FILTERS DONE"); diff --git a/tests/integration/test_binary_sensor_delay_filters.py b/tests/integration/test_binary_sensor_delay_filters.py new file mode 100644 index 00000000000..0b6e4c578d9 --- /dev/null +++ b/tests/integration/test_binary_sensor_delay_filters.py @@ -0,0 +1,85 @@ +"""Pin the output of the binary sensor delay and lambda filters for one input sequence. + +The source goes ON 0 ms, OFF 30 ms, ON 60 ms, OFF 560 ms, ON 710 ms, OFF 1210 ms. +""" + +from __future__ import annotations + +import re + +import pytest + +from .log_utils import LineWaiter +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +OUT_PATTERN = re.compile(r"OUT (\w+)=(ON|OFF) @(\d+)") +SRC_PATTERN = re.compile(r"SRC (ON|OFF) @(\d+)") +# How late an output may land after its due time, for scheduler and CI jitter; well below +# the 100 ms shortest delay, so a skipped delay fails +LATE_MS = 60 +EARLY_MS = 2 + +EXPECTED: dict[str, list[str]] = { + # Short pulses are swallowed; each edge lands 100 ms late + "on_off_single": ["ON", "OFF", "ON", "OFF"], + # The 300 ms off delay outlasts the 150 ms OFF at 560 ms, so only the last OFF shows + "on_off_split": ["ON", "OFF"], + "on_off_lambda": ["ON", "OFF"], + # The delay lambda reads the binary sensor with id `x` (ON, so 100 ms), not the filter input + "on_off_id_x": ["ON", "OFF", "ON", "OFF"], + "delayed_on": ["OFF", "ON", "OFF", "ON", "OFF"], + "delayed_on_lambda": ["OFF", "ON", "OFF", "ON", "OFF"], + "delayed_off": ["ON", "OFF", "ON", "OFF"], + # The lambda inverts and drops its second value; repeats do not reach the sensor + "lambda_filter": ["OFF", "ON", "OFF", "ON"], +} + +# For each output: the source edge (0 to 5) it follows and the delay in ms after that edge +TIMING: dict[str, list[tuple[int, int]]] = { + "on_off_single": [(2, 100), (3, 100), (4, 100), (5, 100)], + "on_off_split": [(2, 100), (5, 300)], + "on_off_lambda": [(2, 100), (5, 300)], + "on_off_id_x": [(2, 100), (3, 100), (4, 100), (5, 100)], + "delayed_on": [(1, 0), (2, 100), (3, 0), (4, 100), (5, 0)], + "delayed_on_lambda": [(1, 0), (2, 100), (3, 0), (4, 100), (5, 0)], + "delayed_off": [(0, 0), (3, 100), (4, 0), (5, 100)], + "lambda_filter": [(0, 0), (3, 0), (4, 0), (5, 0)], +} + + +@pytest.mark.asyncio +async def test_binary_sensor_delay_filters( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each copy sensor runs one filter over the same timed inputs; every output is logged in order.""" + waiter = LineWaiter() + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + done = waiter.expect("FILTERS DONE") + client.button_command(require_entity(entities, "run_filters").key) + await waiter.wait(done, timeout=5.0) + + edges: list[int] = [] + outputs: dict[str, list[str]] = {name: [] for name in EXPECTED} + times: dict[str, list[int]] = {name: [] for name in EXPECTED} + for line in waiter.lines: + if (match := OUT_PATTERN.search(line)) is not None: + outputs[match.group(1)].append(match.group(2)) + times[match.group(1)].append(int(match.group(3))) + elif (match := SRC_PATTERN.search(line)) is not None: + edges.append(int(match.group(2))) + assert len(edges) == 6, edges + assert outputs == EXPECTED + for name, timing in TIMING.items(): + lateness = [ + at - (edges[edge] + delay) + for at, (edge, delay) in zip(times[name], timing, strict=True) + ] + # The source edge is logged in the same millisecond the delay starts, or just after + assert all(-EARLY_MS <= late <= LATE_MS for late in lateness), (name, lateness) diff --git a/tests/integration/test_sensor_filters_apply.py b/tests/integration/test_sensor_filters_apply.py new file mode 100644 index 00000000000..3299fdaaf1c --- /dev/null +++ b/tests/integration/test_sensor_filters_apply.py @@ -0,0 +1,119 @@ +"""Pin the output of the stateless sensor and text sensor filters, for finite, NaN and infinite inputs.""" + +from __future__ import annotations + +import math +import re + +import pytest + +from .log_utils import LineWaiter +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +INPUTS = [-10.0, 0.0, 0.5, 2.25, 12.345, 50.0, 150.0, math.nan, math.inf] +NAN, INF = math.nan, math.inf +OUT_PATTERN = re.compile(r"OUT (\w+)=([^\s\x1b]+)") +TEXT_PATTERN = re.compile(r"TXT (\w+)=([^\s\x1b]*)") + + +def _clamp(value: float, low: float | None = None, high: float | None = None) -> float: + # NaN fails every comparison, so it takes the first bound that is set + if low is not None and not low <= value: + return low + if high is not None and not value <= high: + return high + return value + + +EXPECTED: dict[str, list[float]] = { + "offset_const": [v + 2.5 for v in INPUTS], + "offset_lambda": [v + 1.25 for v in INPUTS], + # The value lambda reads the sensor with id `x` (0.75), not the filter input + "offset_id_x": [v + 0.75 for v in INPUTS], + "multiply_const": [v * 1.5 for v in INPUTS], + # The lambda is `1.0f + 1.0f`, so it must bind as a whole: x * 2, not x * 1 + 1 + "multiply_lambda": [v * 2.0 for v in INPUTS], + "clamp_min": [_clamp(v, low=0.0) for v in INPUTS], + "clamp_max": [_clamp(v, high=10.0) for v in INPUTS], + "clamp_both": [_clamp(v, 0.0, 10.0) for v in INPUTS], + "clamp_ignore": [0.0, 0.5, 2.25], + # Technically wrong: b_constant sets c = 0 and the cubic inverse divides by c, so it gives NaN. + # No known use of to_ntc_resistance with b_constant; switch to the Beta inverse if one appears. + "ntc_resistance": [NAN] * len(INPUTS), + # Computed in double from the float rounded coefficients; non-finite inputs give NaN + "ntc_resistance_abc": [ + 68785.8828, + 40731.2266, + 39711.0898, + 36359.6367, + 22255.3613, + 4505.29346, + 226.547989, + NAN, + NAN, + ], + # The inputs are multiplied by 1000 first, so they are resistances in ohms + "ntc_temperature": [ + NAN, + NAN, + 112.11689, + 62.8283424, + 20.3332348, + -7.29642916, + -25.600666, + NAN, + NAN, + ], + # log(0) is -inf, which the cubic term turns into absolute zero; with c == 0 it is NaN above + "ntc_temperature_abc": [ + NAN, + -273.149994, + 118.490578, + 69.1652069, + 25.3175488, + -3.98647237, + -23.8062897, + NAN, + NAN, + ], + "calibrate_least_squares": [v * 0.95 + 1.0 / 3.0 for v in INPUTS], + "calibrate_exact": [v * 10.0 if v < 10.0 else v * 5.0 + 50.0 for v in INPUTS[:-2]] + + [NAN, INF], + "lambda_filter": [v * 2.0 for v in INPUTS if not v > 100.0], +} + + +TEXT_EXPECTED: dict[str, list[str]] = { + "text_lambda_replace": ["[a]", "[skip]", "[b]"], + "text_lambda_drop": ["a", "b"], +} + + +@pytest.mark.asyncio +async def test_sensor_filters_apply( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each copy sensor runs one filter over the same inputs; every output is logged in order.""" + waiter = LineWaiter() + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + done = waiter.expect("FILTERS DONE") + client.button_command(require_entity(entities, "run_filters").key) + await waiter.wait(done, timeout=5.0) + + outputs: dict[str, list[float]] = {name: [] for name in EXPECTED} + texts: dict[str, list[str]] = {name: [] for name in TEXT_EXPECTED} + for line in waiter.lines: + if (match := OUT_PATTERN.search(line)) is not None: + outputs[match.group(1)].append(float(match.group(2))) + elif (match := TEXT_PATTERN.search(line)) is not None: + texts[match.group(1)].append(match.group(2)) + assert texts == TEXT_EXPECTED + for name, expected in EXPECTED.items(): + assert outputs[name] == pytest.approx(expected, rel=1e-5, nan_ok=True), name