diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e9dceb3b66..4e97c162c11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.17.2 + rev: v0.17.3 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 2a3fd3935fc..810907492f3 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/ld600x/* @hepter @wolph esphome/components/ledc/* @OttoWinter esphome/components/libretiny/* @kuba2k2 esphome/components/libretiny_pwm/* @kuba2k2 diff --git a/esphome/__main__.py b/esphome/__main__.py index 0f3e074f725..2d2c498c0a1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -477,13 +477,20 @@ def has_api() -> bool: def has_ota() -> bool: """Check if any network OTA upload is available. - True if the config exposes either ``platform: esphome`` (native API - OTA) or ``platform: web_server`` (HTTP OTA). Both reach the device - over the same network stack, so the OTA discovery path treats them - interchangeably; ``upload_program`` picks the actual transport based - on ``--ota-platform`` and what's configured. + True if the config exposes ``platform: esphome`` (native API OTA), + ``platform: web_server`` (HTTP OTA), or a network OTA that the target + platform uploads to itself (nRF52 SMP over UDP). The OTA discovery path + treats them interchangeably; ``upload_program`` picks the actual transport. """ - return has_native_ota() or has_web_server_ota() + return has_native_ota() or has_web_server_ota() or has_platform_network_ota() + + +def has_platform_network_ota() -> bool: + """Check if the target platform's ``has_network_ota`` hook reports a network OTA.""" + if (target := CORE.target_platform) is None: + return False + hook = platform_hooks.get_platform_hook(target, "has_network_ota") + return hook is not None and hook(CORE.config) def has_native_ota() -> bool: diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp index bbe95d03bec..7f3cccbfaee 100644 --- a/esphome/components/adc128s102/adc128s102.cpp +++ b/esphome/components/adc128s102/adc128s102.cpp @@ -1,5 +1,6 @@ #include "adc128s102.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::adc128s102 { @@ -22,7 +23,7 @@ uint16_t ADC128S102::read_data(uint8_t channel) { uint8_t adc_secondary_byte = this->transfer_byte(0x00); this->disable(); - uint16_t digital_value = adc_primary_byte << 8 | adc_secondary_byte; + uint16_t digital_value = encode_uint16(adc_primary_byte, adc_secondary_byte); return digital_value; } diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index e99ba6fb98a..aa02357cf0a 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -11,18 +11,16 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; class AHT10Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void update() override; void dump_config() override; void set_variant(AHT10Variant variant) { this->variant_ = variant; } - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } - protected: - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; AHT10Variant variant_{}; unsigned read_count_{}; void read_data_(); diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index 5a5b01ac0b3..b32c968b5cb 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -32,26 +32,21 @@ static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(flow) + SUB_SENSOR(head) + SUB_SENSOR(power) + SUB_SENSOR(current) + SUB_SENSOR(speed) + SUB_SENSOR(voltage) + public: void setup() override; void update() override; void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void dump_config() override; - void set_flow_sensor(sensor::Sensor *sensor) { this->flow_sensor_ = sensor; } - void set_head_sensor(sensor::Sensor *sensor) { this->head_sensor_ = sensor; } - void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } - void set_current_sensor(sensor::Sensor *sensor) { this->current_sensor_ = sensor; } - void set_speed_sensor(sensor::Sensor *sensor) { this->speed_sensor_ = sensor; } - void set_voltage_sensor(sensor::Sensor *sensor) { this->voltage_sensor_ = sensor; } protected: - sensor::Sensor *flow_sensor_{nullptr}; - sensor::Sensor *head_sensor_{nullptr}; - sensor::Sensor *power_sensor_{nullptr}; - sensor::Sensor *current_sensor_{nullptr}; - sensor::Sensor *speed_sensor_{nullptr}; - sensor::Sensor *voltage_sensor_{nullptr}; uint16_t geni_handle_; int16_t response_length_; int16_t response_offset_; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 73dc0d87587..e1a67c29843 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -28,20 +28,17 @@ namespace esphome::am2315c { class AM2315C final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void dump_config() override; void update() override; void setup() override; - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - protected: bool convert_(uint8_t *data, float &humidity, float &temperature); bool reset_register_(uint8_t reg); - - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace esphome::am2315c diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index f92156b1542..aece7a7a374 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -7,20 +7,17 @@ namespace esphome::am2320 { class AM2320Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } - protected: bool read_data_(uint8_t *data); bool read_bytes_(uint8_t a_register, uint8_t *data, uint8_t len, uint32_t conversion = 0); - - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace esphome::am2320 diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 170ff6d86b8..c00c778be74 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -10,24 +10,19 @@ namespace esphome::as5600 { class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { + SUB_SENSOR(raw_position) + SUB_SENSOR(gain) + SUB_SENSOR(magnitude) + SUB_SENSOR(status) + public: void update() override; void dump_config() override; - void set_raw_position_sensor(sensor::Sensor *raw_position_sensor) { - this->raw_position_sensor_ = raw_position_sensor; - } - void set_gain_sensor(sensor::Sensor *gain_sensor) { this->gain_sensor_ = gain_sensor; } - void set_magnitude_sensor(sensor::Sensor *magnitude_sensor) { this->magnitude_sensor_ = magnitude_sensor; } - void set_status_sensor(sensor::Sensor *status_sensor) { this->status_sensor_ = status_sensor; } void set_out_of_range_mode(OutRangeMode oor_mode) { this->out_of_range_mode_ = oor_mode; } OutRangeMode get_out_of_range_mode() { return this->out_of_range_mode_; } protected: - sensor::Sensor *raw_position_sensor_{nullptr}; - sensor::Sensor *gain_sensor_{nullptr}; - sensor::Sensor *magnitude_sensor_{nullptr}; - sensor::Sensor *status_sensor_{nullptr}; OutRangeMode out_of_range_mode_{OUT_RANGE_MODE_MIN_MAX}; }; diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index fa6b35fc745..04bd870a532 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -1,6 +1,7 @@ #include "atm90e26.h" #include "atm90e26_reg.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::atm90e26 { @@ -138,7 +139,7 @@ uint16_t ATM90E26Component::read16_(uint8_t a_register) { this->read_array(data, 2); this->disable(); - output = (uint16_t(data[0] & 0xFF) << 8) | (data[1] & 0xFF); + output = encode_uint16(data[0], data[1]); ESP_LOGVV(TAG, "read16_ 0x%04X output 0x%04X", a_register, output); return output; } diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 0381d8e5c16..e2286682fc3 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -9,19 +9,20 @@ namespace esphome::atm90e26 { class ATM90E26Component final : public PollingComponent, public spi::SPIDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(power) + SUB_SENSOR(reactive_power) + SUB_SENSOR(forward_active_energy) + SUB_SENSOR(reverse_active_energy) + SUB_SENSOR(power_factor) + SUB_SENSOR(freq) + public: void setup() override; void dump_config() override; void update() override; - void set_voltage_sensor(sensor::Sensor *obj) { this->voltage_sensor_ = obj; } - void set_current_sensor(sensor::Sensor *obj) { this->current_sensor_ = obj; } - void set_power_sensor(sensor::Sensor *obj) { this->power_sensor_ = obj; } - void set_reactive_power_sensor(sensor::Sensor *obj) { this->reactive_power_sensor_ = obj; } - void set_forward_active_energy_sensor(sensor::Sensor *obj) { this->forward_active_energy_sensor_ = obj; } - void set_reverse_active_energy_sensor(sensor::Sensor *obj) { this->reverse_active_energy_sensor_ = obj; } - void set_power_factor_sensor(sensor::Sensor *obj) { this->power_factor_sensor_ = obj; } - void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } void set_line_freq(int freq) { line_freq_ = freq; } void set_meter_constant(float val) { meter_constant_ = val; } void set_pl_const(uint32_t pl_const) { pl_const_ = pl_const; } @@ -46,14 +47,6 @@ class ATM90E26Component final : public PollingComponent, float get_frequency_(); float get_chip_temperature_(); - sensor::Sensor *freq_sensor_{nullptr}; - sensor::Sensor *voltage_sensor_{nullptr}; - sensor::Sensor *current_sensor_{nullptr}; - sensor::Sensor *power_sensor_{nullptr}; - sensor::Sensor *reactive_power_sensor_{nullptr}; - sensor::Sensor *power_factor_sensor_{nullptr}; - sensor::Sensor *forward_active_energy_sensor_{nullptr}; - sensor::Sensor *reverse_active_energy_sensor_{nullptr}; uint32_t cumulative_forward_active_energy_{0}; uint32_t cumulative_reverse_active_energy_{0}; uint16_t gain_metering_{7481}; diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 1968c3a314f..af6b13166f9 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -1,6 +1,7 @@ #include "bl0939.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include namespace esphome::bl0939 { @@ -104,7 +105,7 @@ void BL0939::received_package_(const DataPacket *data) const { PUBLISH_STATE(this->energy_sum_sensor_, total_energy_consumption); if (this->internal_temperature_sensor_ != nullptr) { - const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF; + const uint16_t raw_temperature = encode_uint16(data->tps1.h, data->tps1.l) & 0x3FF; // Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45. const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f; this->internal_temperature_sensor_->publish_state(temperature); @@ -132,8 +133,8 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_); } -uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } +uint32_t BL0939::to_uint32_t(ube24_t input) { return encode_uint24(input.h, input.m, input.l); } -int32_t BL0939::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; } +int32_t BL0939::to_int32_t(sbe24_t input) { return sign_extend<24>(encode_uint24(input.h, input.m, input.l)); } } // namespace esphome::bl0939 diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 5dc95fffe56..5079115a17f 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -51,8 +51,6 @@ 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; -inline uint16_t combine_bytes(uint8_t msb, uint8_t lsb) { return ((msb & 0xFF) << 8) | (lsb & 0xFF); } - const char *iir_filter_to_str(BME280IIRFilter filter) { // NOLINT switch (filter) { case BME280_IIR_FILTER_OFF: @@ -290,7 +288,7 @@ float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { } float BME280Component::read_humidity_(const uint8_t *data, int32_t t_fine) { - uint16_t const raw_adc = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF); + uint16_t const raw_adc = encode_uint16(data[6], data[7]); if (raw_adc == 0x8000) return NAN; diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 5ddb3cfd456..f62964473ad 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -57,11 +57,11 @@ enum BME280IIRFilter { /// This class implements support for the BME280 Temperature+Pressure+Humidity sensor. class BME280Component : public PollingComponent { - public: - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + SUB_SENSOR(humidity) + public: /// Set the oversampling value for the temperature sensor. Default is 16x. void set_temperature_oversampling(BME280Oversampling temperature_over_sampling); /// Set the oversampling value for the pressure sensor. Default is 16x. @@ -98,9 +98,6 @@ class BME280Component : public PollingComponent { BME280Oversampling pressure_oversampling_{BME280_OVERSAMPLING_16X}; BME280Oversampling humidity_oversampling_{BME280_OVERSAMPLING_16X}; BME280IIRFilter iir_filter_{BME280_IIR_FILTER_OFF}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *pressure_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace esphome::bme280_base diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 7a3e9e775f5..f17c04fcd7d 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -98,7 +98,7 @@ bool BME680Component::setup_registers_() { this->calibration_.t3 = coeff1[3]; this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); - this->calibration_.h2 = coeff2[0] << 4 | coeff2[1] >> 4; + this->calibration_.h2 = encode_uint16(coeff2[0], coeff2[1]) >> 4; this->calibration_.h3 = coeff2[3]; this->calibration_.h4 = coeff2[4]; this->calibration_.h5 = coeff2[5]; @@ -268,10 +268,10 @@ void BME680Component::read_data_() { return; } - uint32_t raw_temperature = (uint32_t(data[5]) << 12) | (uint32_t(data[6]) << 4) | (uint32_t(data[7]) >> 4); - uint32_t raw_pressure = (uint32_t(data[2]) << 12) | (uint32_t(data[3]) << 4) | (uint32_t(data[4]) >> 4); - uint32_t raw_humidity = (uint32_t(data[8]) << 8) | uint32_t(data[9]); - uint16_t raw_gas = (uint16_t) ((uint32_t) data[13] * 4 | (((uint32_t) data[14]) / 64)); + uint32_t raw_temperature = encode_uint24(data[5], data[6], data[7]) >> 4; + uint32_t raw_pressure = encode_uint24(data[2], data[3], data[4]) >> 4; + uint32_t raw_humidity = encode_uint16(data[8], data[9]); + uint16_t raw_gas = encode_uint16(data[13], data[14]) >> 6; uint8_t gas_range = data[14] & 0x0F; float temperature = this->calc_temperature_(raw_temperature); diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index 64fa20f2fb1..1e6e969b170 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -66,6 +66,11 @@ struct BME680CalibrationData { }; class BME680Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + SUB_SENSOR(humidity) + SUB_SENSOR(gas_resistance) + public: /// Set the temperature oversampling value. Defaults to 16X. void set_temperature_oversampling(BME680Oversampling temperature_oversampling); @@ -76,13 +81,6 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the IIR Filter value. Defaults to no IIR Filter. void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } - void set_gas_resistance_sensor(sensor::Sensor *gas_resistance_sensor) { - gas_resistance_sensor_ = gas_resistance_sensor; - } - /** Set how the internal heater should operate. * * By default, the heater is off. If you want to to have more reliable @@ -128,11 +126,6 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { BME680IIRFilter iir_filter_{BME680_IIR_FILTER_OFF}; uint16_t heater_temperature_{320}; uint16_t heater_duration_{150}; - - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *pressure_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; - sensor::Sensor *gas_resistance_sensor_{nullptr}; }; } // namespace esphome::bme680 diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index eb0b553cc24..8f12d084e8a 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -7,29 +7,21 @@ namespace esphome::bmi160 { class BMI160Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(accel_x) + SUB_SENSOR(accel_y) + SUB_SENSOR(accel_z) + SUB_SENSOR(temperature) + SUB_SENSOR(gyro_x) + SUB_SENSOR(gyro_y) + SUB_SENSOR(gyro_z) + public: void setup() override; void dump_config() override; void update() override; - void set_accel_x_sensor(sensor::Sensor *accel_x_sensor) { accel_x_sensor_ = accel_x_sensor; } - void set_accel_y_sensor(sensor::Sensor *accel_y_sensor) { accel_y_sensor_ = accel_y_sensor; } - void set_accel_z_sensor(sensor::Sensor *accel_z_sensor) { accel_z_sensor_ = accel_z_sensor; } - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_gyro_x_sensor(sensor::Sensor *gyro_x_sensor) { gyro_x_sensor_ = gyro_x_sensor; } - void set_gyro_y_sensor(sensor::Sensor *gyro_y_sensor) { gyro_y_sensor_ = gyro_y_sensor; } - void set_gyro_z_sensor(sensor::Sensor *gyro_z_sensor) { gyro_z_sensor_ = gyro_z_sensor; } - protected: - sensor::Sensor *accel_x_sensor_{nullptr}; - sensor::Sensor *accel_y_sensor_{nullptr}; - sensor::Sensor *accel_z_sensor_{nullptr}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *gyro_x_sensor_{nullptr}; - sensor::Sensor *gyro_y_sensor_{nullptr}; - sensor::Sensor *gyro_z_sensor_{nullptr}; - void internal_setup_(int stage); /// Run one setup stage; false on an I2C failure. bool setup_stage_(int stage); diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index ba4b9f3faef..73127bfb686 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -30,17 +30,17 @@ void BMP085Component::setup() { } // Load calibration - this->calibration_.ac1 = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF); - this->calibration_.ac2 = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); - this->calibration_.ac3 = ((data[4] & 0xFF) << 8) | (data[5] & 0xFF); - this->calibration_.ac4 = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF); - this->calibration_.ac5 = ((data[8] & 0xFF) << 8) | (data[9] & 0xFF); - this->calibration_.ac6 = ((data[10] & 0xFF) << 8) | (data[11] & 0xFF); - this->calibration_.b1 = ((data[12] & 0xFF) << 8) | (data[13] & 0xFF); - this->calibration_.b2 = ((data[14] & 0xFF) << 8) | (data[15] & 0xFF); - this->calibration_.mb = ((data[16] & 0xFF) << 8) | (data[17] & 0xFF); - this->calibration_.mc = ((data[18] & 0xFF) << 8) | (data[19] & 0xFF); - this->calibration_.md = ((data[20] & 0xFF) << 8) | (data[21] & 0xFF); + this->calibration_.ac1 = encode_uint16(data[0], data[1]); + this->calibration_.ac2 = encode_uint16(data[2], data[3]); + this->calibration_.ac3 = encode_uint16(data[4], data[5]); + this->calibration_.ac4 = encode_uint16(data[6], data[7]); + this->calibration_.ac5 = encode_uint16(data[8], data[9]); + this->calibration_.ac6 = encode_uint16(data[10], data[11]); + this->calibration_.b1 = encode_uint16(data[12], data[13]); + this->calibration_.b2 = encode_uint16(data[14], data[15]); + this->calibration_.mb = encode_uint16(data[16], data[17]); + this->calibration_.mc = encode_uint16(data[18], data[19]); + this->calibration_.md = encode_uint16(data[20], data[21]); } void BMP085Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP085:"); @@ -59,7 +59,7 @@ void BMP085Component::read_temperature_() { return; } - int32_t ut = ((buffer[0] & 0xFF) << 8) | (buffer[1] & 0xFF); + int32_t ut = encode_uint16(buffer[0], buffer[1]); if (ut == 0) { ESP_LOGW(TAG, "Invalid temperature!"); this->status_set_warning(); diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 8d29f4599ab..346eba0fb68 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -20,8 +20,6 @@ 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; -inline uint16_t combine_bytes(uint8_t msb, uint8_t lsb) { return ((msb & 0xFF) << 8) | (lsb & 0xFF); } - static const char *oversampling_to_str(BMP280Oversampling oversampling) { switch (oversampling) { case BMP280_OVERSAMPLING_NONE: diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index cee8f43c7c2..368ab1f06a5 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -50,10 +50,10 @@ enum BMP280IIRFilter { /// This class implements support for the BMP280 Temperature+Pressure sensor. class BMP280Component : public PollingComponent { - public: - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + public: /// Set the oversampling value for the temperature sensor. Default is 16x. void set_temperature_oversampling(BMP280Oversampling temperature_over_sampling); /// Set the oversampling value for the pressure sensor. Default is 16x. @@ -83,8 +83,6 @@ class BMP280Component : public PollingComponent { BMP280Oversampling temperature_oversampling_{BMP280_OVERSAMPLING_16X}; BMP280Oversampling pressure_oversampling_{BMP280_OVERSAMPLING_16X}; BMP280IIRFilter iir_filter_{BMP280_IIR_FILTER_OFF}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *pressure_sensor_{nullptr}; }; } // namespace esphome::bmp280_base diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.h b/esphome/components/bmp3xx_base/bmp3xx_base.h index c3e30890411..e9c5b6b40f8 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.h +++ b/esphome/components/bmp3xx_base/bmp3xx_base.h @@ -69,14 +69,14 @@ enum IIRFilter { /// This class implements support for the BMP3XX Temperature+Pressure sensor. class BMP3XXComponent : public PollingComponent { + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + public: void setup() override; void dump_config() override; void update() override; - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } - /// Set the oversampling value for the temperature sensor. Default is 16x. void set_temperature_oversampling_config(Oversampling temperature_oversampling) { this->temperature_oversampling_ = temperature_oversampling; @@ -122,8 +122,6 @@ class BMP3XXComponent : public PollingComponent { Oversampling pressure_oversampling_{OVERSAMPLING_X16}; IIRFilter iir_filter_{IIR_FILTER_OFF}; OperationMode operation_mode_{FORCED_MODE}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *pressure_sensor_{nullptr}; struct { // The BMP388 compensation trim parameters (coefficients) uint16_t param_T1; diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index abd7f64b4d2..edb6f6ca793 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -59,15 +59,15 @@ enum IIRFilter { }; class BMP581Component : public PollingComponent { + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + public: void dump_config() override; void setup() override; void update() override; - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } - void set_temperature_oversampling_config(Oversampling temperature_oversampling) { this->temperature_oversampling_ = temperature_oversampling; } @@ -91,9 +91,6 @@ class BMP581Component : public PollingComponent { // Interface activation function. Only used for SPI interface; no-op for I2C. virtual void activate_interface() {} - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *pressure_sensor_{nullptr}; - Oversampling temperature_oversampling_; Oversampling pressure_oversampling_; diff --git a/esphome/components/bp5758d/bp5758d.cpp b/esphome/components/bp5758d/bp5758d.cpp index 5789acdcb97..5aee47b2746 100644 --- a/esphome/components/bp5758d/bp5758d.cpp +++ b/esphome/components/bp5758d/bp5758d.cpp @@ -25,8 +25,6 @@ void BP5758D::setup() { this->clock_pin_->setup(); this->clock_pin_->digital_write(false); delayMicroseconds(BP5758D_DELAY); - this->channel_current_.resize(5, 0); - this->pwm_amounts_.resize(5, 0); } void BP5758D::dump_config() { ESP_LOGCONFIG(TAG, "BP5758D:"); @@ -117,7 +115,6 @@ uint8_t BP5758D::correct_current_level_bits_(uint8_t current) { void BP5758D::set_channel_value_(uint8_t channel, uint16_t value) { if (this->pwm_amounts_[channel] != value) { this->update_ = true; - this->update_channel_ = channel; } this->pwm_amounts_[channel] = value; } diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index 572108b4e67..84cf835f015 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/output/float_output.h" #include +#include namespace esphome::bp5758d { @@ -53,9 +54,8 @@ class BP5758D final : public Component { GPIOPin *data_pin_; GPIOPin *clock_pin_; - uint8_t update_channel_; - std::vector channel_current_; - std::vector pwm_amounts_; + std::array channel_current_{}; + std::array pwm_amounts_{}; bool update_{true}; }; diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index dcd00886013..586ca5e3a5f 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -130,9 +130,8 @@ bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *dat return false; // Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data - uint8_t sum = static_cast(subcommand & 0xFF) + static_cast(subcommand >> 8); - for (size_t i = 0; i < len; ++i) - sum += data[i]; + const uint8_t sum = + sum_bytes(data, len, static_cast(subcommand & 0xFF) + static_cast(subcommand >> 8)); const uint8_t tail[2] = {static_cast(~sum), static_cast(4 + len)}; return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2); } diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h index ccc4f45cfaf..0be9dff8210 100644 --- a/esphome/components/bq76972/bq76972.h +++ b/esphome/components/bq76972/bq76972.h @@ -23,7 +23,6 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } #ifdef USE_SENSOR - void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } #endif @@ -49,7 +48,8 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { bool crc_mode_{false}; #ifdef USE_SENSOR - sensor::Sensor *stack_voltage_sensor_{nullptr}; + SUB_SENSOR(stack_voltage) + protected: std::array cell_sensors_{}; std::array temperature_sensors_{}; #endif diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 62cc166e77c..cc80c88c4f3 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -140,8 +140,9 @@ void CCS811Component::send_env_data_() { // https://cdn.sparkfun.com/datasheets/BreakoutBoards/CCS811_Programming_Guide.pdf // Reference code: // https://github.com/adafruit/Adafruit_CCS811/blob/0990f5c620354d8bc087c4706bec091d8e6e5dfd/Adafruit_CCS811.cpp#L135-L142 - uint16_t hum_conv = static_cast(lroundf(humidity * 512.0f + 0.5f)); - uint16_t temp_conv = static_cast(lroundf(temperature * 512.0f + 0.5f)); + // lroundf already rounds to nearest; the reference adds 0.5 only because it truncates. + uint16_t hum_conv = static_cast(lroundf(humidity * 512.0f)); + uint16_t temp_conv = static_cast(lroundf(temperature * 512.0f)); this->write_bytes(0x05, {(uint8_t) ((hum_conv >> 8) & 0xff), (uint8_t) ((hum_conv & 0xff)), (uint8_t) ((temp_conv >> 8) & 0xff), (uint8_t) ((temp_conv & 0xff))}); } diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index e3e41606bce..d7e8d457039 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -1,4 +1,5 @@ #include "cm1106.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -17,11 +18,7 @@ static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0 static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0; static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2; uint8_t cm1106_checksum(const uint8_t *response, size_t len) { - uint8_t crc = 0; - for (size_t i = 0; i < len - 1; i++) { - crc -= response[i]; - } - return crc; + return static_cast(-sum_bytes(response, len - 1)); } void CM1106Component::setup() { @@ -57,7 +54,7 @@ void CM1106Component::update() { return; } - const uint16_t ppm = response[3] << 8 | response[4]; + const uint16_t ppm = encode_uint16(response[3], response[4]); const uint8_t status = response[5]; if (status & 0x1) { ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm); diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 1066a5c44dd..28cedb7e624 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -15,6 +15,8 @@ enum CM1106ABCLogic : uint8_t { }; class CM1106Component final : public PollingComponent, public uart::UARTDevice { + SUB_SENSOR(co2) + public: void setup() override; void update() override; @@ -24,7 +26,6 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { void abc_enable() { this->abc_set_(true); } void abc_disable() { this->abc_set_(false); } - void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) { this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED; this->abc_cycle_ = cycle_days; @@ -37,7 +38,6 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack); void abc_set_(bool enabled); - sensor::Sensor *co2_sensor_{nullptr}; CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE}; uint8_t abc_cycle_{15}; uint16_t abc_baseline_{400}; diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 1ade9f94b08..a83f304be6d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -88,6 +88,7 @@ CONF_SOURCES = "sources" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" +CONF_TEMPERATURE_SENSOR = "temperature_sensor" CONF_USE_PSRAM = "use_psram" CONF_VALUE_TYPE = "value_type" CONF_VERIFY_SSL = "verify_ssl" diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 80a63d808fb..b9a7e2196ce 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -54,6 +54,10 @@ enum CS5460APGAGain { class CS5460AComponent final : public Component, public spi::SPIDevice { + SUB_SENSOR(current) + SUB_SENSOR(voltage) + SUB_SENSOR(power) + public: void set_samples(uint32_t samples) { samples_ = samples; } void set_phase_offset(int8_t phase_offset) { phase_offset_ = phase_offset; } @@ -67,9 +71,6 @@ class CS5460AComponent final : public Component, voltage_hpf_ = voltage_hpf; } void set_pulse_energy_wh(float pulse_energy_wh) { pulse_energy_wh_ = pulse_energy_wh; } - void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } - void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } - void set_power_sensor(sensor::Sensor *power_sensor) { power_sensor_ = power_sensor; } void restart() { restart_(); } @@ -85,9 +86,6 @@ class CS5460AComponent final : public Component, bool current_hpf_; bool voltage_hpf_; float pulse_energy_wh_; - sensor::Sensor *current_sensor_{nullptr}; - sensor::Sensor *voltage_sensor_{nullptr}; - sensor::Sensor *power_sensor_{nullptr}; void write_register_(enum CS5460ARegister addr, uint32_t value); uint32_t read_register_(uint8_t addr); diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 1206ad4e71f..68721e8db84 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -10,19 +10,15 @@ namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; class CSE7766Component final : public Component, public uart::UARTDevice { - public: - void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } - void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } - void set_power_sensor(sensor::Sensor *power_sensor) { power_sensor_ = power_sensor; } - void set_energy_sensor(sensor::Sensor *energy_sensor) { energy_sensor_ = energy_sensor; } - void set_apparent_power_sensor(sensor::Sensor *apparent_power_sensor) { - apparent_power_sensor_ = apparent_power_sensor; - } - void set_reactive_power_sensor(sensor::Sensor *reactive_power_sensor) { - reactive_power_sensor_ = reactive_power_sensor; - } - void set_power_factor_sensor(sensor::Sensor *power_factor_sensor) { power_factor_sensor_ = power_factor_sensor; } + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(power) + SUB_SENSOR(energy) + SUB_SENSOR(apparent_power) + SUB_SENSOR(reactive_power) + SUB_SENSOR(power_factor) + public: void loop() override; void dump_config() override; @@ -38,13 +34,6 @@ class CSE7766Component final : public Component, public uart::UARTDevice { uint8_t raw_data_index_{0}; uint16_t cf_pulses_last_{0}; uint32_t last_transmission_{0}; - sensor::Sensor *voltage_sensor_{nullptr}; - sensor::Sensor *current_sensor_{nullptr}; - sensor::Sensor *power_sensor_{nullptr}; - sensor::Sensor *energy_sensor_{nullptr}; - sensor::Sensor *apparent_power_sensor_{nullptr}; - sensor::Sensor *reactive_power_sensor_{nullptr}; - sensor::Sensor *power_factor_sensor_{nullptr}; uint32_t cf_pulses_total_{0}; // Power cycle of the last frame that carried a new power measurement, 0 if none uint32_t last_power_cycle_{0}; diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index cb21404284a..f656e45012c 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -151,8 +151,8 @@ uint8_t DaikinBrcClimate::temperature_() { uint8_t temperature; // Temperature in remote is in F if (this->fahrenheit_) { - temperature = (uint8_t) roundf( - clamp(((this->target_temperature * 1.8f) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); + temperature = (uint8_t) roundf(clamp(celsius_to_fahrenheit(this->target_temperature), + DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); } else { temperature = ((uint8_t) roundf(this->target_temperature) - 9) << 1; } diff --git a/esphome/components/delta_wallbox/delta_wallbox.cpp b/esphome/components/delta_wallbox/delta_wallbox.cpp index 4af72e3796b..d970e8f9b99 100644 --- a/esphome/components/delta_wallbox/delta_wallbox.cpp +++ b/esphome/components/delta_wallbox/delta_wallbox.cpp @@ -102,7 +102,7 @@ static bool verify_crc(const uint8_t *data, size_t len) { if (len < EXCEPTION_RESPONSE_LEN) return false; const uint16_t calculated = crc16(data, static_cast(len - 2)); - const uint16_t received = data[len - 2] | (data[len - 1] << 8); + const uint16_t received = encode_uint16(data[len - 1], data[len - 2]); return calculated == received; } @@ -335,7 +335,7 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) { } case DeltaState::DELTA_STATE_CHECK_PAIRING: { if (frame[2] >= REGISTER_BYTE_COUNT) { - uint16_t status = (frame[3] << 8) | frame[4]; + uint16_t status = encode_uint16(frame[3], frame[4]); ESP_LOGI(TAG, "Pairing Status Register: 0x%04X", status); if (status == PAIRING_STATUS_READY) { this->pairing_in_progress_ = false; @@ -464,13 +464,13 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) { this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE)); } else if (byte_count == REGISTER_BYTE_COUNT) { // Error status register (0x01B5) - uint16_t reg_val = (frame[3] << 8) | frame[4]; + uint16_t reg_val = encode_uint16(frame[3], frame[4]); char hex_str[16]; snprintf(hex_str, sizeof(hex_str), "%04X", reg_val); PUBLISH_STATE(this->error_code_text_sensor_, hex_str); } } else if (frame[1] == FC_WRITE_SINGLE_REGISTER) { - const uint16_t register_addr = (static_cast(frame[2]) << 8) | frame[3]; + const uint16_t register_addr = encode_uint16(frame[2], frame[3]); if (register_addr == REG_MAX_CURRENT) { ESP_LOGI(TAG, "Wallbox max current command acknowledged: %u A", frame[5]); PUBLISH_STATE(this->max_current_number_, frame[5]); diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 86292e144f9..010f972721c 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -19,6 +19,9 @@ enum DHTModel : uint8_t { /// Component for reading temperature/humidity measurements from DHT11/DHT22 sensors. class DHT final : public PollingComponent { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: /** Manually select the DHT model. * @@ -41,8 +44,6 @@ class DHT final : public PollingComponent { this->t_pin_ = pin; this->pin_ = pin->to_isr(); } - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } /// Set up the pins and check connection. void setup() override; @@ -53,8 +54,6 @@ class DHT final : public PollingComponent { protected: bool read_sensor_(float *temperature, float *humidity, bool report_errors); - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; InternalGPIOPin *t_pin_; ISRInternalGPIOPin pin_; DHTModel model_{DHT_MODEL_AUTO_DETECT}; diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index b835ac16489..1c3c05bab47 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -7,19 +7,16 @@ namespace esphome::dht12 { class DHT12Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } - protected: bool read_data_(uint8_t *data); - - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace esphome::dht12 diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 0bbfe984a78..ad41ca26930 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -24,8 +24,7 @@ void EE895Component::setup() { crc16_check = (serial_number[19] << 8) + serial_number[18]; if (crc16_check != calc_crc16_(serial_number, 18)) { - this->error_code_ = CRC_CHECK_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("CRC check failed")); return; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -37,17 +36,6 @@ void EE895Component::setup() { void EE895Component::dump_config() { ESP_LOGCONFIG(TAG, "EE895:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case CRC_CHECK_FAILED: - ESP_LOGE(TAG, "The crc check failed"); - break; - case NONE: - default: - break; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); @@ -92,8 +80,7 @@ float EE895Component::read_float_() { this->read(i2c_response, 8); crc16_check = (i2c_response[7] << 8) + i2c_response[6]; if (crc16_check != calc_crc16_(i2c_response, 6)) { - this->error_code_ = CRC_CHECK_FAILED; - this->status_set_warning(); + this->status_set_warning(LOG_STR("CRC check failed")); return 0; } uint32_t x = encode_uint32(i2c_response[4], i2c_response[5], i2c_response[2], i2c_response[3]); diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index b66827486c6..5c4151f6ce7 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -21,8 +21,6 @@ class EE895Component final : public PollingComponent, public i2c::I2CDevice { void write_command_(uint16_t addr, uint16_t reg_cnt); float read_float_(); uint16_t calc_crc16_(const uint8_t buf[], uint8_t len); - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; } // namespace esphome::ee895 diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index c6ae5169e4c..6c05fcc8579 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -3,6 +3,7 @@ // - Official Datasheet: https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include "emc2101.h" namespace esphome::emc2101 { @@ -143,7 +144,7 @@ float Emc2101Component::get_external_temperature() { } // join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t - int16_t raw = static_cast((msb << 8) | lsb) >> 5; + int16_t raw = static_cast(encode_uint16(msb, lsb)) >> 5; return raw * 0.125f; } @@ -157,7 +158,7 @@ float Emc2101Component::get_speed() { } // calculate RPMs - uint16_t tach = msb << 8 | lsb; + uint16_t tach = encode_uint16(msb, lsb); return tach == 0xFFFF ? 0.0f : 5400000.0f / tach; } diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 37bc8d00edb..cc98625dc24 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -1,6 +1,7 @@ #include "fingerprint_grow.h" #include "esphome/core/gpio.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include namespace esphome::fingerprint_grow { @@ -126,8 +127,8 @@ void FingerprintGrowComponent::scan_and_match_() { switch (this->send_command_()) { case OK: { ESP_LOGD(TAG, "Fingerprint matched"); - uint16_t finger_id = ((uint16_t) this->data_[1] << 8) | this->data_[2]; - uint16_t confidence = ((uint16_t) this->data_[3] << 8) | this->data_[4]; + uint16_t finger_id = encode_uint16(this->data_[1], this->data_[2]); + uint16_t confidence = encode_uint16(this->data_[3], this->data_[4]); PUBLISH_STATE(this->last_finger_id_sensor_, finger_id); PUBLISH_STATE(this->last_confidence_sensor_, confidence); this->finger_scan_matched_callback_.call(finger_id, confidence); @@ -255,11 +256,11 @@ bool FingerprintGrowComponent::get_parameters_() { if (this->send_command_() == OK) { ESP_LOGD(TAG, "Got parameters"); // Bear in mind data_[0] is the transfer status, the parameters table start at data_[1] - PUBLISH_STATE(this->status_sensor_, ((uint16_t) this->data_[1] << 8) | this->data_[2]); - this->system_identifier_code_ = ((uint16_t) this->data_[3] << 8) | this->data_[4]; - this->capacity_ = ((uint16_t) this->data_[5] << 8) | this->data_[6]; + PUBLISH_STATE(this->status_sensor_, encode_uint16(this->data_[1], this->data_[2])); + this->system_identifier_code_ = encode_uint16(this->data_[3], this->data_[4]); + this->capacity_ = encode_uint16(this->data_[5], this->data_[6]); PUBLISH_STATE(this->capacity_sensor_, this->capacity_); - PUBLISH_STATE(this->security_level_sensor_, ((uint16_t) this->data_[7] << 8) | this->data_[8]); + PUBLISH_STATE(this->security_level_sensor_, encode_uint16(this->data_[7], this->data_[8])); PUBLISH_STATE(this->enrolling_binary_sensor_, false); this->get_fingerprint_count_(); return true; @@ -272,7 +273,7 @@ void FingerprintGrowComponent::get_fingerprint_count_() { this->data_ = {TEMPLATE_COUNT}; if (this->send_command_() == OK) { ESP_LOGD(TAG, "Got fingerprint count"); - PUBLISH_STATE(this->fingerprint_count_sensor_, ((uint16_t) this->data_[1] << 8) | this->data_[2]); + PUBLISH_STATE(this->fingerprint_count_sensor_, encode_uint16(this->data_[1], this->data_[2])); } } diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index b5e1c98bcb2..ae09dc8807e 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -1,5 +1,6 @@ #include "fs3000.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::fs3000 { @@ -49,7 +50,7 @@ void FS3000Component::update() { } // raw value information is 12 bits - uint16_t raw_value = (data[1] << 8) | data[2]; + uint16_t raw_value = encode_uint16(data[1], data[2]); ESP_LOGV(TAG, "Got raw reading=%i", raw_value); // convert and publish the raw value into m/s using the table of data points in the datasheet diff --git a/esphome/components/ft63x6/ft63x6.cpp b/esphome/components/ft63x6/ft63x6.cpp index 598215e258d..e0589c66333 100644 --- a/esphome/components/ft63x6/ft63x6.cpp +++ b/esphome/components/ft63x6/ft63x6.cpp @@ -7,6 +7,7 @@ #include "ft63x6.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" // Registers // Reference: https://focuslcds.com/content/FT6236.pdf @@ -95,13 +96,13 @@ uint16_t FT63X6Touchscreen::read_touch_x_(uint8_t touch) { uint8_t read_buf[2]; read_buf[0] = this->read_byte_(FT63X6_ADDR_TOUCH1_X + (touch * 6)); read_buf[1] = this->read_byte_(FT63X6_ADDR_TOUCH1_X + 1 + (touch * 6)); - return ((read_buf[0] & 0x0f) << 8) | read_buf[1]; + return encode_uint16(read_buf[0] & 0x0f, read_buf[1]); } uint16_t FT63X6Touchscreen::read_touch_y_(uint8_t touch) { uint8_t read_buf[2]; read_buf[0] = this->read_byte_(FT63X6_ADDR_TOUCH1_Y + (touch * 6)); read_buf[1] = this->read_byte_(FT63X6_ADDR_TOUCH1_Y + 1 + (touch * 6)); - return ((read_buf[0] & 0x0f) << 8) | read_buf[1]; + return encode_uint16(read_buf[0] & 0x0f, read_buf[1]); } uint8_t FT63X6Touchscreen::read_touch_event_(uint8_t touch) { return this->read_byte_(FT63X6_ADDR_TOUCH1_X + (touch * 6)) >> 6; diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index ddfe77d3ac1..759db9c6849 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -1,4 +1,5 @@ #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include "hm3301.h" namespace esphome::hm3301 { @@ -83,7 +84,7 @@ void HM3301Component::update() { bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { - return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; + return encode_uint16(data[i * 2], data[i * 2 + 1]); } } // namespace esphome::hm3301 diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index a5c922a940b..a0161de1347 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -4,6 +4,7 @@ #include "honeywell_hih.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::honeywell_hih_i2c { @@ -22,10 +23,10 @@ void HoneywellHIComponent::read_sensor_data_() { return; } - const uint16_t raw_humidity = (static_cast(data[0] & 0x3F) << 8) | data[1]; + const uint16_t raw_humidity = encode_uint16(data[0] & 0x3F, data[1]); float humidity = (static_cast(raw_humidity) / MAX_COUNT) * 100; - const uint16_t raw_temperature = (static_cast(data[2]) << 6) | (data[3] >> 2); + const uint16_t raw_temperature = encode_uint16(data[2], data[3]) >> 2; float temperature = (static_cast(raw_temperature) / MAX_COUNT) * 165 - 40; ESP_LOGD(TAG, "Got temperature=%.2f°C humidity=%.2f%%", temperature, humidity); diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index f5ee30cf3ae..2e212013fbd 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -11,10 +11,12 @@ static constexpr size_t HTE501_SERIAL_NUMBER_SIZE = 7; void HTE501Component::setup() { uint8_t address[] = {0x70, 0x29}; uint8_t identification[9]; - this->write_read(address, sizeof address, identification, sizeof identification); + if (this->write_read(address, sizeof address, identification, sizeof identification) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { - this->error_code_ = CRC_CHECK_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("CRC check failed")); return; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -26,17 +28,6 @@ void HTE501Component::setup() { void HTE501Component::dump_config() { ESP_LOGCONFIG(TAG, "HTE501:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case CRC_CHECK_FAILED: - ESP_LOGE(TAG, "The crc check failed"); - break; - case NONE: - default: - break; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); @@ -44,22 +35,22 @@ void HTE501Component::dump_config() { void HTE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2); + if (this->write(address_1, 2) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } this->set_timeout(50, [this]() { uint8_t i2c_response[6]; - this->read(i2c_response, 6); - if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true) || - i2c_response[5] != crc8(i2c_response + 3, 2, 0xFF, 0x31, true)) { - this->error_code_ = CRC_CHECK_FAILED; - this->status_set_warning(); + if (this->read(i2c_response, 6) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - float temperature = (float) encode_uint16(i2c_response[0], i2c_response[1]); - if (temperature > 55536) { - temperature = (temperature - 65536) / 100; - } else { - temperature = temperature / 100; + if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true) || + i2c_response[5] != crc8(i2c_response + 3, 2, 0xFF, 0x31, true)) { + this->status_set_warning(LOG_STR("CRC check failed")); + return; } + float temperature = static_cast(encode_uint16(i2c_response[0], i2c_response[1])) / 100.0f; float humidity = ((float) encode_uint16(i2c_response[3], i2c_response[4])) / 100.0f; ESP_LOGD(TAG, "Got temperature=%.2f°C humidity=%.2f%%", temperature, humidity); diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 0744a75f1c2..cd2054c19ec 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -15,9 +15,6 @@ class HTE501Component final : public PollingComponent, public i2c::I2CDevice { void setup() override; void dump_config() override; void update() override; - - protected: - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; } // namespace esphome::hte501 diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 0dda1c3ec86..a083ac14d1a 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -1,6 +1,7 @@ #include "hyt271.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::hyt271 { @@ -32,8 +33,8 @@ void HYT271Component::update() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); return; } - uint16_t raw_temperature = ((raw_data[2] << 8) | raw_data[3]) >> 2; - uint16_t raw_humidity = ((raw_data[0] & 0x3F) << 8) | raw_data[1]; + uint16_t raw_temperature = encode_uint16(raw_data[2], raw_data[3]) >> 2; + uint16_t raw_humidity = encode_uint16(raw_data[0] & 0x3F, raw_data[1]); float temperature = ((float(raw_temperature)) * (165.0f / 16383.0f)) - 40.0f; float humidity = (float(raw_humidity)) * (100.0f / 16383.0f); diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 4f81f91ee58..2cae9c9ad39 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -206,7 +206,7 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_ return; } - uint16_t recv_command = msg[2] << 8 | msg[3]; + uint16_t recv_command = encode_uint16(msg[2], msg[3]); if (recv_command != command) { ESP_LOGE(TAG, "Received invalid message (invalid unexpected command received 0x%04X, expected 0x%04X)", recv_command, command); diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a97f5217a85..8e5e87351ad 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -192,7 +192,7 @@ uint8_t Lc709203f::get_register_(uint8_t register_to_read, uint16_t *register_va snprintf(buf, sizeof(buf), "CRC error reading from register 0x%02X", register_to_read); this->status_set_warning(buf); } else { - *register_value = ((uint16_t) read_buffer[4] << 8) | (uint16_t) read_buffer[3]; + *register_value = encode_uint16(read_buffer[4], read_buffer[3]); return i2c::NO_ERROR; } } diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 12cacd2e345..a0a70df41ce 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -1,20 +1,26 @@ from esphome import pins 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, +) import esphome.config_validation as cv -from esphome.const import CONF_PLATFORM, CONF_WAKEUP_PIN -import esphome.final_validate as fv +from esphome.const import CONF_WAKEUP_PIN from esphome.types import ConfigType -from .const import CONF_AREA_CONFIG, CONF_AUTO_WAKE, CONF_LD6002B_ID, CONF_WAKEUP_PULSE +from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE +AUTO_LOAD = ["ld600x"] CODEOWNERS = ["@hepter"] DEPENDENCIES = ["uart"] DOMAIN = "ld6002b" MULTI_CONF = True ld6002b_ns = cg.esphome_ns.namespace("ld6002b") -LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) +LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", LD600XComponent) def _validate_wakeup_options(config: ConfigType) -> ConfigType: @@ -37,66 +43,26 @@ def _validate_wakeup_options(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( _validate_wakeup_options, - cv.Schema( + ld600x_hub_schema(LD6002BComponent).extend( { - cv.GenerateID(): cv.declare_id(LD6002BComponent), cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, cv.Optional( CONF_WAKEUP_PULSE, default="50ms" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean, } - ) - .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + ), ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "ld6002b", - baud_rate=115200, - require_tx=True, - require_rx=True, - data_bits=8, - parity="NONE", - stop_bits=1, -) +FINAL_VALIDATE_SCHEMA = uart_final_validate("ld6002b") async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) + request_model_sizes(var, area_kinds=2) await cg.add_pin(var.set_wakeup_pin, config.get(CONF_WAKEUP_PIN)) cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE])) - - -def validate_unique_settings(domain: str, config: ConfigType) -> None: - """Reject a setting set in two platform blocks of one hub, which keeps one entity per setting.""" - hub_id = config[CONF_LD6002B_ID] - entries = [ - entry - for entry in fv.full_config.get().get(domain, []) - if entry.get(CONF_PLATFORM) == DOMAIN and entry.get(CONF_LD6002B_ID) == hub_id - ] - for key, value in config.items(): - if key in (CONF_PLATFORM, CONF_LD6002B_ID): - continue - # area_config groups six settings, so each one is checked on its own - for path in ( - [[key, sub] for sub in value] if key == CONF_AREA_CONFIG else [[key]] - ): - if sum(_has_path(entry, path) for entry in entries) > 1: - raise cv.Invalid( - f"{'.'.join(path)} is already set for this ld6002b in another {domain} block", - path=path, - ) - - -def _has_path(entry: ConfigType, path: list[str]) -> bool: - for key in path: - if key not in entry: - return False - entry = entry[key] - return True diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 36405f06602..9d6c13b3a88 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -1,54 +1,13 @@ -import esphome.codegen as cg -from esphome.components import binary_sensor -import esphome.config_validation as cv -from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.components.ld600x import entities from esphome.types import ConfigType from . import LD6002BComponent -from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS +from .const import CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ), - } - ) - .extend( - { - cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(MAX_TARGETS) - } - ) - .extend( - { - cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(AREA_COUNT) - } - ) -) +CONFIG_SCHEMA = entities.binary_sensor_schema(LD6002BComponent, CONF_LD6002B_ID) async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_TARGET, hub.set_presence_binary_sensor) - - for i in range(MAX_TARGETS): - if target_config := config.get(f"target_{i + 1}"): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_target_presence_binary_sensor(i, sens)) - - for i in range(AREA_COUNT): - if area_config := config.get(f"detection_area_{i}"): - sens = await binary_sensor.new_binary_sensor(area_config) - cg.add(hub.set_area_presence_binary_sensor(i, sens)) + await entities.binary_sensor_to_code(config, CONF_LD6002B_ID) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 60cf7d73fd9..e0d7ff2589d 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -1,164 +1,44 @@ -from esphome.automation import parent_ref -import esphome.codegen as cg -from esphome.components import button -from esphome.components.select import DOMAIN as SELECT_DOMAIN +from esphome.components.ld600x import entities import esphome.config_validation as cv -from esphome.const import ( - CONF_AREA_ID, - CONF_ID, - CONF_WAKEUP_PIN, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -) -from esphome.cpp_generator import ParameterListExpression +from esphome.const import CONF_WAKEUP_PIN +from esphome.core import ID import esphome.final_validate as fv from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns -from ..const import ( - CONF_APPLY_AREA, - CONF_AUTO_INTERFERENCE, - CONF_CLEAR_INTERFERENCE, - CONF_GET_AREAS, - CONF_GET_DELAY, - CONF_GET_INSTALLATION, - CONF_GET_LOW_POWER_MODE, - CONF_GET_LOW_POWER_SLEEP_TIME, - CONF_GET_SENSITIVITY, - CONF_GET_TRIGGER_SPEED, - CONF_GET_Z_RANGE, - CONF_LD6002B_ID, - CONF_RESET_DETECTION_AREA, - CONF_RESET_UNATTENDED, - CONF_WAKE, -) +from ..const import CONF_LD6002B_ID, CONF_WAKE DEPENDENCIES = ["ld6002b"] -ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) +LD6002BButtonType = ld6002b_ns.enum("LD6002BButtonType") -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_APPLY_AREA): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_GET_AREAS): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_GET_DELAY): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_SENSITIVITY): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_Z_RANGE): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_INSTALLATION): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_RESET_UNATTENDED): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_WAKE): button.button_schema( - button.ApplyButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - } +CONFIG_SCHEMA = entities.button_schema( + LD6002BComponent, + CONF_LD6002B_ID, + extra={cv.Optional(CONF_WAKE): entities.DIAGNOSTIC_BUTTON_SCHEMA}, ) +_shared_final_validate = entities.button_final_validate(CONF_LD6002B_ID, "ld6002b") -def final_validate(config: ConfigType) -> None: - full_config = fv.full_config.get() - hub_id = config[CONF_LD6002B_ID] - - if config.get(CONF_APPLY_AREA): - has_area_id_select = any( - entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get(SELECT_DOMAIN, []) +def _final_validate(config: ConfigType) -> None: + _shared_final_validate(config) + if not config.get(CONF_WAKE): + return + hub_id: ID = config[CONF_LD6002B_ID] + hub_config = fv.get_declaring_config(hub_id, fv.full_config.get()) + if hub_config.get(CONF_WAKEUP_PIN) is None: + raise cv.Invalid( + f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", + path=[CONF_WAKE], ) - if not has_area_id_select: - raise cv.Invalid( - f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", - path=[CONF_APPLY_AREA], - ) - - if config.get(CONF_WAKE): - hub_config = fv.get_declaring_config(hub_id, full_config) - if hub_config.get(CONF_WAKEUP_PIN) is None: - raise cv.Invalid( - f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", - path=[CONF_WAKE], - ) -FINAL_VALIDATE_SCHEMA = final_validate - -BUTTON_MAP = { - CONF_APPLY_AREA: ButtonType.APPLY_AREA, - CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, - CONF_GET_AREAS: ButtonType.GET_AREAS, - CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, - CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, - CONF_GET_DELAY: ButtonType.GET_DELAY, - CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, - CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, - CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE, - CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION, - CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE, - CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME, - CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED, - CONF_WAKE: ButtonType.WAKE, -} +FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - confs = [ - (button_type, conf) - for key, button_type in BUTTON_MAP.items() - if (conf := config.get(key)) is not None - ] - if not confs: - return - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - params = button.APPLY_ENTITY.params - # One function for all buttons, picking the type by pointer: one class instead of one per button. - # Named after the first button so two button blocks on one hub do not clash. - fn = f"esphome__{confs[0][1][CONF_ID].id}__button_fn" - cg.add_global( - cg.RawStatement(f"static void {fn}({ParameterListExpression(*params)});") - ) - template = cg.TemplateArguments(cg.RawExpression(fn)) - created = [ - (button_type, await button.new_button(conf, template)) - for button_type, conf in confs - ] - *head, (last_type, _) = created - chain = "".join(f"entity == {parent_ref(var)} ? {t} : " for t, var in head) - cg.static_function( - fn, - cg.void, - list(params), - [ - f"const auto type = {chain}{last_type};", - f"{parent_ref(hub)}->press_button(type);", - ], + await entities.button_to_code( + config, + CONF_LD6002B_ID, + extra={CONF_WAKE: LD6002BButtonType.BUTTON_WAKE}, ) diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index b7c3f54a6f9..881bea1cd28 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,41 +1,6 @@ -CONF_APPLY_AREA = "apply_area" -CONF_AREA_CONFIG = "area_config" -CONF_AUTO_INTERFERENCE = "auto_interference" +AREA_KINDS = ("interference", "detection") CONF_AUTO_WAKE = "auto_wake" -CONF_CLEAR_INTERFERENCE = "clear_interference" -CONF_CLUSTER_ID = "cluster_id" -CONF_DOPPLER_INDEX = "doppler_index" -CONF_GET_AREAS = "get_areas" -CONF_GET_DELAY = "get_delay" -CONF_GET_INSTALLATION = "get_installation" -CONF_GET_LOW_POWER_MODE = "get_low_power_mode" -CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" -CONF_GET_SENSITIVITY = "get_sensitivity" -CONF_GET_TRIGGER_SPEED = "get_trigger_speed" -CONF_GET_Z_RANGE = "get_z_range" -CONF_HOLD_DELAY = "hold_delay" -CONF_INSTALLATION_MODE = "installation_mode" CONF_LD6002B_ID = "ld6002b_id" CONF_LOW_POWER = "low_power" -CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" -CONF_OTA_VERSION = "ota_version" -CONF_POINT_CLOUD = "point_cloud" -CONF_POINT_COUNT = "point_count" -CONF_RESET_DETECTION_AREA = "reset_detection_area" -CONF_RESET_UNATTENDED = "reset_unattended" -CONF_TARGET_DISPLAY = "target_display" -CONF_TRIGGER_SPEED = "trigger_speed" CONF_WAKE = "wake" CONF_WAKEUP_PULSE = "wakeup_pulse" -CONF_WORK_MODE = "work_mode" -CONF_Z = "z" -CONF_Z_MAX = "z_max" -CONF_Z_MIN = "z_min" - -KEY_X_MIN = "x_min" -KEY_X_MAX = "x_max" -KEY_Y_MIN = "y_min" -KEY_Y_MAX = "y_max" - -AREA_COUNT = 4 -MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 104c9a35640..b8f153ac375 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -1,920 +1,82 @@ #include "ld6002b.h" #include "esphome/core/log.h" -#include #include -#include -#include #include namespace esphome::ld6002b { ESPHOME_LOG_TAG(TAG, "ld6002b"); -static constexpr uint8_t TF_SOF = 0x01; -static constexpr uint32_t SETUP_DELAY_MS = 100; - -// Command/message types -static constexpr uint16_t TYPE_CONTROL = 0x0201; -static constexpr uint16_t TYPE_SET_AREA = 0x0202; -static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; -static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; -static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; - -static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; -static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; -static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; -static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; -static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; -static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; -static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; -static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; -static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; -static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; -static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; -static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; -// Control command values for TYPE_CONTROL -static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; -static constexpr uint32_t CMD_GET_AREAS = 0x02; -static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; -static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; -static constexpr uint32_t CMD_GET_DELAY = 0x05; -static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; -static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; -static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; -static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; -static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; -static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; -static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; -static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; -static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; -static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; -static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; -static constexpr uint32_t CMD_GET_TRIGGER = 0x11; -static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; -static constexpr uint32_t CMD_INSTALL_TOP = 0x13; -static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; -static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; -static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; -static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; - -static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id -static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats -static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats -static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area - -static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display - -static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; - -#ifdef ESPHOME_LOG_HAS_VERBOSE -static const char *control_command_name(uint32_t command) { - switch (command) { - case CMD_AUTO_INTERFERENCE: - return "auto_interference"; - case CMD_GET_AREAS: - return "get_areas"; - case CMD_CLEAR_INTERFERENCE: - return "clear_interference"; - case CMD_RESET_DETECTION_AREA: - return "reset_detection_area"; - case CMD_GET_DELAY: - return "get_delay"; - case CMD_POINT_CLOUD_ON: - return "point_cloud_on"; - case CMD_POINT_CLOUD_OFF: - return "point_cloud_off"; - case CMD_TARGET_DISPLAY_ON: - return "target_display_on"; - case CMD_TARGET_DISPLAY_OFF: - return "target_display_off"; - case CMD_SENSITIVITY_LOW: - return "sensitivity_low"; - case CMD_SENSITIVITY_MEDIUM: - return "sensitivity_medium"; - case CMD_SENSITIVITY_HIGH: - return "sensitivity_high"; - case CMD_GET_SENSITIVITY: - return "get_sensitivity"; - case CMD_TRIGGER_SLOW: - return "trigger_slow"; - case CMD_TRIGGER_MEDIUM: - return "trigger_medium"; - case CMD_TRIGGER_FAST: - return "trigger_fast"; - case CMD_GET_TRIGGER: - return "get_trigger"; - case CMD_GET_Z_RANGE: - return "get_z_range"; - case CMD_INSTALL_TOP: - return "install_top"; - case CMD_INSTALL_SIDE: - return "install_side"; - case CMD_GET_INSTALLATION: - return "get_installation"; - case CMD_LOW_POWER_ON: - return "low_power_on"; - case CMD_LOW_POWER_OFF: - return "low_power_off"; - case CMD_GET_LOW_POWER: - return "get_low_power"; - case CMD_GET_LOW_POWER_SLEEP: - return "get_low_power_sleep"; - case CMD_RESET_UNATTENDED: - return "reset_unattended"; - default: - return "unknown"; - } -} - -static const char *frame_type_name(uint16_t type) { - switch (type) { - case TYPE_CONTROL: - return "control"; - case TYPE_SET_AREA: - return "set_area"; - case TYPE_SET_HOLD_DELAY: - return "set_hold_delay"; - case TYPE_SET_Z_RANGE: - return "set_z_range"; - case TYPE_SET_LOW_POWER_SLEEP: - return "set_low_power_sleep"; - case TYPE_REPORT_TARGET: - return "report_target"; - case TYPE_REPORT_POINT_CLOUD: - return "report_point_cloud"; - case TYPE_REPORT_AREA_PRESENCE: - return "report_area_presence"; - case TYPE_REPORT_INTERFERENCE_AREAS: - return "report_interference_areas"; - case TYPE_REPORT_DETECTION_AREAS: - return "report_detection_areas"; - case TYPE_REPORT_DELAY: - return "report_delay"; - case TYPE_REPORT_SENSITIVITY: - return "report_sensitivity"; - case TYPE_REPORT_TRIGGER: - return "report_trigger"; - case TYPE_REPORT_Z_RANGE: - return "report_z_range"; - case TYPE_REPORT_INSTALLATION: - return "report_installation"; - case TYPE_REPORT_LOW_POWER: - return "report_low_power"; - case TYPE_REPORT_LOW_POWER_SLEEP: - return "report_low_power_sleep"; - case TYPE_REPORT_WORK_MODE: - return "report_work_mode"; - case TYPE_QUERY_VERSION: - return "query_version"; - default: - return "unknown"; - } -} - -static bool is_expected_control_report(uint32_t command, uint16_t type) { - switch (command) { - case CMD_GET_AREAS: - return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; - case CMD_GET_DELAY: - return type == TYPE_REPORT_DELAY; - case CMD_GET_SENSITIVITY: - return type == TYPE_REPORT_SENSITIVITY; - case CMD_GET_TRIGGER: - return type == TYPE_REPORT_TRIGGER; - case CMD_GET_Z_RANGE: - return type == TYPE_REPORT_Z_RANGE; - case CMD_GET_INSTALLATION: - return type == TYPE_REPORT_INSTALLATION; - case CMD_GET_LOW_POWER: - case CMD_LOW_POWER_ON: - case CMD_LOW_POWER_OFF: - return type == TYPE_REPORT_LOW_POWER; - case CMD_GET_LOW_POWER_SLEEP: - return type == TYPE_REPORT_LOW_POWER_SLEEP; - default: - return false; - } -} -#endif - -float LD6002BComponent::read_f32_le(const uint8_t *data) { - uint32_t raw = encode_value_le(data); - float value; - std::memcpy(&value, &raw, sizeof(value)); - return value; -} - -void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { - data[0] = value & 0xFF; - data[1] = (value >> 8) & 0xFF; - data[2] = (value >> 16) & 0xFF; - data[3] = (value >> 24) & 0xFF; -} - -void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { - write_u32_le(data, static_cast(value)); -} - -void LD6002BComponent::write_f32_le(uint8_t *data, float value) { - uint32_t raw; - std::memcpy(&raw, &value, sizeof(raw)); - write_u32_le(data, raw); -} void LD6002BComponent::setup() { - // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. - bool point_cloud_configured = false; -#ifdef USE_SENSOR - point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr; -#endif -#ifdef USE_SWITCH - point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; -#endif - this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; - // One allocation for the component lifetime; the parser reuses it for the header and every payload. - RAMAllocator allocator; - this->data_buf_ = allocator.allocate(this->max_data_len_); - if (this->data_buf_ == nullptr) { - this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + this->LD600XComponent::setup(); + if (this->is_failed()) return; - } if (this->wakeup_pin_ != nullptr) { this->wakeup_pin_->setup(); this->wakeup_pin_->digital_write(true); } - - this->set_timeout(SETUP_DELAY_MS, [this]() { - bool want_target_stream = false; -#ifdef USE_SENSOR - want_target_stream = want_target_stream || this->target_count_sensor_ != nullptr; - if (!want_target_stream) { - for (const auto &target : this->targets_) { - if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr || - target.cluster_id != nullptr) { - want_target_stream = true; - break; - } - } - } -#endif -#ifdef USE_BINARY_SENSOR - want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; - if (!want_target_stream) { - for (auto *sensor : this->target_presence_) { - if (sensor != nullptr) { - want_target_stream = true; - break; - } - } - } -#endif -#ifdef USE_TEXT_SENSOR - // The work mode fallback reads presence off this stream, so it counts as a - // consumer of it here. This only feeds the automatic branch below: with a - // target_display switch configured that switch still decides, and the - // fallback weighs no presence at all while the stream is off. - want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr; -#endif - bool target_display_controlled = false; -#ifdef USE_SWITCH - if (this->target_display_switch_ != nullptr) { - target_display_controlled = true; - // Nothing reports this switch back, so its restored state is the only state there is. - this->target_display_switch_->restore(true); - } -#endif - if (!target_display_controlled) { - // No switch: the stream follows its consumers. With none, nothing is sent - // and the module's own default stands -- but the reports are gated out - // regardless, because there is nothing configured for them to feed. - this->target_display_enabled_ = want_target_stream; - if (want_target_stream) { - this->send_control_command_(CMD_TARGET_DISPLAY_ON); - } - } - - bool point_cloud_controlled = false; -#ifdef USE_SWITCH - if (this->point_cloud_switch_ != nullptr) { - point_cloud_controlled = true; - this->point_cloud_switch_->restore(false); - } -#endif - if (!point_cloud_controlled) { - // No switch: the stream follows the sensor that reads it, which is also what - // the frame buffer above was sized for. - bool want_point_cloud = false; -#ifdef USE_SENSOR - want_point_cloud = this->point_count_sensor_ != nullptr; -#endif - this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); - this->point_cloud_enabled_ = want_point_cloud; - } - -#ifdef USE_SELECT - if (this->sensitivity_select_ != nullptr) { - this->send_control_command_(CMD_GET_SENSITIVITY); - } - if (this->trigger_speed_select_ != nullptr) { - this->send_control_command_(CMD_GET_TRIGGER); - } - if (this->installation_select_ != nullptr) { - this->send_control_command_(CMD_GET_INSTALLATION); - } -#endif -#ifdef USE_NUMBER - if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { - this->send_control_command_(CMD_GET_Z_RANGE); - } - if (this->low_power_sleep_number_ != nullptr) { - this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); - } - if (this->hold_delay_number_ != nullptr) { - this->send_control_command_(CMD_GET_DELAY); - } -#endif -#ifdef USE_SWITCH - bool want_low_power = this->low_power_switch_ != nullptr; - if (want_low_power) { - // The query below confirms what the module took; until it lands, the work mode - // fallback runs on the restored value. - this->low_power_switch_->restore(false); - } -#else - bool want_low_power = false; -#endif -#ifdef USE_TEXT_SENSOR - want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; -#endif - if (want_low_power) { - this->send_control_command_(CMD_GET_LOW_POWER); - } - - bool want_area_report = false; -#ifdef USE_SENSOR - for (const auto &area : this->interference_areas_) { - if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || - area.z_min != nullptr || area.z_max != nullptr) { - want_area_report = true; - break; - } - } - if (!want_area_report) { - for (const auto &area : this->detection_areas_) { - if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || - area.z_min != nullptr || area.z_max != nullptr) { - want_area_report = true; - break; - } - } - } -#endif -#ifdef USE_NUMBER - if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || - this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || - this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { - want_area_report = true; - } -#endif - if (want_area_report) { - this->send_control_command_(CMD_GET_AREAS); - } - - this->init_area_id_pref_(); - this->init_version_pref_(); - -#ifdef USE_TEXT_SENSOR - if (this->ota_version_text_sensor_ != nullptr) { - this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); - } -#endif - }); } -void LD6002BComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "HLK-LD6002B:\n" - " Auto wake: %s\n" - " Max data length: %u", - this->auto_wake_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false"), - static_cast(this->max_data_len_)); +void LD6002BComponent::setup_model() { +#ifdef USE_SWITCH + bool want_low_power = this->low_power_switch_ != nullptr; + if (want_low_power) { + // The query below confirms what the module took; until it lands, the work mode + // fallback runs on the restored value. + this->low_power_switch_->restore(false); + } +#else + bool want_low_power = false; +#endif +#ifdef USE_TEXT_SENSOR + want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_low_power) { + this->send_control_command_(CMD_GET_LOW_POWER); + } +} + +void LD6002BComponent::dump_model_config() { + ESP_LOGCONFIG(TAG, " Auto wake: %s", this->auto_wake_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); } -#ifdef USE_SENSOR - LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); - LOG_SENSOR(" ", "Point Count", this->point_count_sensor_); - for (auto &target : this->targets_) { - LOG_SENSOR(" ", "Target X", target.x); - LOG_SENSOR(" ", "Target Y", target.y); - LOG_SENSOR(" ", "Target Z", target.z); - LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); - LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); - } - for (auto &area : this->interference_areas_) { - LOG_SENSOR(" ", "Interference Area X Min", area.x_min); - LOG_SENSOR(" ", "Interference Area X Max", area.x_max); - LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); - LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); - LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); - LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); - } - for (auto &area : this->detection_areas_) { - LOG_SENSOR(" ", "Detection Area X Min", area.x_min); - LOG_SENSOR(" ", "Detection Area X Max", area.x_max); - LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); - LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); - LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); - LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); - } -#endif -#ifdef USE_BINARY_SENSOR - LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); - for (uint8_t i = 0; i < MAX_TARGETS; i++) { - LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); - } - for (uint8_t i = 0; i < AREA_COUNT; i++) { - LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); - } -#endif -#ifdef USE_TEXT_SENSOR - LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); - LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); -#endif -#ifdef USE_NUMBER - LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); - LOG_NUMBER(" ", "Z Min", this->z_min_number_); - LOG_NUMBER(" ", "Z Max", this->z_max_number_); - LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); - LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); - LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); - LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); - LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); - LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); - LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); -#endif #ifdef USE_SWITCH LOG_SWITCH(" ", "Low Power", this->low_power_switch_); - LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); - LOG_SWITCH(" ", "Target Display", this->target_display_switch_); -#endif -#ifdef USE_SELECT - LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); - LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); - LOG_SELECT(" ", "Installation Mode", this->installation_select_); - LOG_SELECT(" ", "Area ID", this->area_id_select_); #endif } -void LD6002BComponent::loop() { - this->read_available([this](uint8_t byte) { this->parse_byte_(byte); }); - this->process_command_queue_(); -} - -void LD6002BComponent::reset_parser_() { - this->parse_state_ = ParseState::SOF; - this->header_pos_ = 0; - this->header_xor_ = 0; - this->data_len_ = 0; - this->data_pos_ = 0; - this->data_xor_ = 0; - this->discard_remaining_ = 0; - this->frame_oversize_ = false; -} - -void LD6002BComponent::parse_byte_(uint8_t byte) { - switch (this->parse_state_) { - case ParseState::DISCARD: - // discard_remaining_ is unsigned: an unguarded decrement at zero would swallow 4 GB of stream. - if (this->discard_remaining_ > 0) { - this->discard_remaining_--; - } - if (this->discard_remaining_ == 0) { - this->reset_parser_(); - } - return; - case ParseState::SOF: - if (byte != TF_SOF) - return; - this->header_pos_ = 0; - this->header_xor_ = 0; - this->header_xor_ ^= byte; - this->parse_state_ = ParseState::HEADER; - return; - case ParseState::HEADER: - if (this->header_pos_ < 6) { - this->data_buf_[this->header_pos_] = byte; - this->header_xor_ ^= byte; - this->header_pos_++; - if (this->header_pos_ == 6) { - this->frame_id_ = encode_value(this->data_buf_); - this->data_len_ = encode_value(this->data_buf_ + 2); - this->frame_type_ = encode_value(this->data_buf_ + 4); - // The length is only trustworthy once the header checksum has been verified, so just - // remember that the frame is oversized and let the HCK state act on it. - this->frame_oversize_ = this->data_len_ > this->max_data_len_; - this->parse_state_ = ParseState::HCK; - } - } - return; - case ParseState::HCK: { - uint8_t expected = static_cast(~this->header_xor_); - if (byte != expected) { - ESP_LOGV(TAG, "Header checksum mismatch"); - this->reset_parser_(); - return; - } - if (this->frame_oversize_) { - ESP_LOGW(TAG, "Frame too large: %u", this->data_len_); - // The header is verified, so the length can be trusted: skip the payload and its checksum. - this->discard_remaining_ = static_cast(this->data_len_) + 1; - this->parse_state_ = ParseState::DISCARD; - return; - } - if (this->data_len_ == 0) { - this->handle_frame_(this->frame_type_, nullptr, 0); - this->reset_parser_(); - } else { - this->data_pos_ = 0; - this->data_xor_ = 0; - this->parse_state_ = ParseState::DATA; - } - return; - } - case ParseState::DATA: - this->data_buf_[this->data_pos_++] = byte; - this->data_xor_ ^= byte; - if (this->data_pos_ >= this->data_len_) { - this->parse_state_ = ParseState::DCK; - } - return; - case ParseState::DCK: { - uint8_t expected = static_cast(~this->data_xor_); - if (byte == expected) { - this->handle_frame_(this->frame_type_, this->data_buf_, this->data_len_); - } else { - ESP_LOGV(TAG, "Data checksum mismatch"); - } - this->reset_parser_(); - return; - } - } -} - -void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) { - this->last_traffic_ms_ = millis(); - if (this->stale_ack_count_ > 0 && millis() - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) { - this->stale_ack_count_ = 0; - } - // ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command. - if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) { - this->stale_ack_count_--; - ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type, - this->frame_id_); - return; - } - if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { - ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); - const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; - // This settles one expected reply; the rest stay owed and become the debt for the next command. - this->send_generation_++; - this->stale_ack_type_ = type; - this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast(this->acks_expected_ - 1) : 0; - this->stale_ack_ms_ = millis(); - this->command_active_ = false; - this->command_sent_ = false; - this->last_send_ms_ = 0; - this->process_command_queue_(); - if (refresh_areas) { - this->area_write_in_flight_ = false; - this->set_timeout(AREA_REFRESH_TIMEOUT_ID, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); - } - return; - } - -#ifdef ESPHOME_LOG_HAS_VERBOSE - const uint32_t active_control_command = - (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? encode_value_le(this->active_command_.data.data()) - : 0; - if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { - ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, - control_command_name(active_control_command), active_control_command); - } +bool LD6002BComponent::model_wants_target_stream() const { +#ifdef USE_TEXT_SENSOR + // The work mode fallback reads presence off this stream. It weighs no target + // presence at all while the stream is off. + return this->work_mode_text_sensor_ != nullptr; +#else + return false; #endif +} +bool LD6002BComponent::handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) { switch (type) { - case TYPE_REPORT_TARGET: - this->handle_target_report_(data, len); - break; - case TYPE_REPORT_POINT_CLOUD: - this->handle_point_cloud_(data, len); - break; - case TYPE_REPORT_AREA_PRESENCE: - this->handle_area_presence_(data, len); - break; - case TYPE_REPORT_INTERFERENCE_AREAS: - this->handle_area_report_(true, data, len); - break; - case TYPE_REPORT_DETECTION_AREAS: - this->handle_area_report_(false, data, len); - break; - case TYPE_REPORT_DELAY: - this->handle_delay_report_(data, len); - break; - case TYPE_REPORT_SENSITIVITY: - this->handle_sensitivity_report_(data, len); - break; - case TYPE_REPORT_TRIGGER: - this->handle_trigger_speed_report_(data, len); - break; - case TYPE_REPORT_Z_RANGE: - this->handle_z_range_report_(data, len); - break; - case TYPE_REPORT_INSTALLATION: - this->handle_installation_report_(data, len); - break; case TYPE_REPORT_LOW_POWER: this->handle_low_power_report_(data, len); - break; - case TYPE_REPORT_LOW_POWER_SLEEP: - this->handle_low_power_sleep_report_(data, len); - break; + return true; case TYPE_REPORT_WORK_MODE: this->handle_work_mode_report_(data, len); - break; - case TYPE_QUERY_VERSION: - this->handle_version_report_(data, len); - break; + return true; default: - break; + return false; } } -void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { - // The module stops streaming when it acts on the command, not when the command - // is queued, so trailing frames after an off must not repopulate what - // set_switch_state just cleared. - if (!this->target_display_enabled_) { - return; - } - if (len < 4) - return; - - uint32_t target_num = encode_value_le(data); - uint16_t available = (len - 4) / TARGET_DATA_LEN; - // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". - const uint32_t reported = std::min(target_num, available); - uint8_t count = static_cast(std::min(reported, MAX_TARGETS)); - - // The module re-sorts its array by cluster id, so slots key on the id to track the person. - std::array wire_cluster{}; - std::array wire_placed{}; - std::array slot_seen{}; - std::array slot_wire{}; - for (uint8_t i = 0; i < count; i++) { - uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; - wire_cluster[i] = encode_value_le(data + cluster_offset); - } - for (uint8_t i = 0; i < count; i++) { - for (uint8_t s = 0; s < MAX_TARGETS; s++) { - if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { - slot_seen[s] = true; - wire_placed[i] = true; - slot_wire[s] = i; - break; - } - } - } - for (uint8_t s = 0; s < MAX_TARGETS; s++) { - if (!slot_seen[s]) { - this->slot_occupied_[s] = false; - } - } - for (uint8_t i = 0; i < count; i++) { - if (wire_placed[i]) { - continue; - } - for (uint8_t s = 0; s < MAX_TARGETS; s++) { - if (!this->slot_occupied_[s]) { - this->slot_occupied_[s] = true; - this->slot_cluster_[s] = wire_cluster[i]; - slot_wire[s] = i; - break; - } - } - } - -#ifdef USE_SENSOR - if (this->target_count_sensor_ != nullptr) { - if (reported != this->last_target_count_) { - this->target_count_sensor_->publish_state(reported); - this->last_target_count_ = reported; - } - } -#endif - - this->target_presence_any_ = (reported > 0); -#ifdef USE_BINARY_SENSOR - bool presence = this->target_presence_any_ || this->area_presence_any_; - PUBLISH_STATE(this->presence_binary_sensor_, presence); -#endif - this->update_work_mode_fallback_(); - - for (uint8_t i = 0; i < MAX_TARGETS; i++) { - bool has_target = this->slot_occupied_[i]; - if (has_target) { -#ifdef USE_SENSOR - uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN); - float x = read_f32_le(data + offset + 0); - float y = read_f32_le(data + offset + 4); - float z = read_f32_le(data + offset + 8); - int32_t dop_idx = encode_value_le(data + offset + 12); - int32_t cluster_id = this->slot_cluster_[i]; - TargetSensors &target = this->targets_[i]; - PUBLISH_STATE(target.x, x); - PUBLISH_STATE(target.y, y); - PUBLISH_STATE(target.z, z); - PUBLISH_STATE(target.dop_idx, static_cast(dop_idx)); - if (target.cluster_id != nullptr) { - if (!this->last_cluster_id_valid_[i] || cluster_id != this->last_cluster_id_[i]) { - target.cluster_id->publish_state(static_cast(cluster_id)); - this->last_cluster_id_[i] = cluster_id; - this->last_cluster_id_valid_[i] = true; - } - } -#endif - } else { -#ifdef USE_SENSOR - this->clear_target_slot_(i); -#endif - } -#ifdef USE_BINARY_SENSOR - // publish_state() already skips unchanged states, no manual de-dup needed. - PUBLISH_STATE(this->target_presence_[i], has_target); -#endif -#ifdef USE_SENSOR - this->last_target_presence_[i] = has_target; -#endif - } -} - -void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { - // Same window as the target stream: a frame already in flight must not put the - // count back after the switch cleared it. - if (!this->point_cloud_enabled_) { - return; - } - if (len < 4) - return; - -#ifdef USE_SENSOR - uint32_t point_num = encode_value_le(data); - if (this->point_count_sensor_ != nullptr) { - if (point_num != this->last_point_count_) { - this->point_count_sensor_->publish_state(point_num); - this->last_point_count_ = point_num; - } - } -#endif -} - -// 0x0A0A carries one uint32 per detection area -- the protocol names the four -// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The -// interference areas have no presence report: a target inside one is what they -// exist to suppress. -void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { - const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; - if (len < needed) - return; - - this->area_presence_any_ = false; - for (uint8_t i = 0; i < AREA_COUNT; i++) { - uint32_t state = encode_value_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); - bool present = state != 0; - this->area_presence_any_ = this->area_presence_any_ || present; -#ifdef USE_BINARY_SENSOR - PUBLISH_STATE(this->area_presence_[i], present); -#endif - } - -#ifdef USE_BINARY_SENSOR - bool presence = this->target_presence_any_ || this->area_presence_any_; - PUBLISH_STATE(this->presence_binary_sensor_, presence); -#endif - this->update_work_mode_fallback_(); -} - -void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { - uint16_t needed = AREA_COUNT * AREA_DATA_LEN; - if (len < needed) - return; - - for (uint8_t i = 0; i < AREA_COUNT; i++) { - uint16_t offset = i * AREA_DATA_LEN; - float x_min = read_f32_le(data + offset + 0); - float x_max = read_f32_le(data + offset + 4); - float y_min = read_f32_le(data + offset + 8); - float y_max = read_f32_le(data + offset + 12); - float z_min = read_f32_le(data + offset + 16); - float z_max = read_f32_le(data + offset + 20); - -#ifdef USE_SENSOR - AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; - PUBLISH_STATE(area.x_min, x_min); - PUBLISH_STATE(area.x_max, x_max); - PUBLISH_STATE(area.y_min, y_min); - PUBLISH_STATE(area.y_max, y_max); - PUBLISH_STATE(area.z_min, z_min); - PUBLISH_STATE(area.z_max, z_max); -#endif - - AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; - store.x_min = x_min; - store.x_max = x_max; - store.y_min = y_min; - store.y_max = y_max; - store.z_min = z_min; - store.z_max = z_max; - - uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; - bool selected_interference = selected_id < AREA_COUNT; - uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); - if (selected_interference == interference && selected_index == i) { - this->update_area_numbers_(store); - } - } - this->try_apply_pending_area_(interference); -} - -void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { - if (len < 4) - return; -#ifdef USE_NUMBER - uint32_t delay = encode_value_le(data); - this->publish_number_clamped_(this->hold_delay_number_, delay); -#endif -} - -void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { - if (len < 1) - return; -#ifdef USE_SELECT - if (this->sensitivity_select_ == nullptr) - return; - uint8_t value = data[0]; - if (value <= 2) { - this->sensitivity_select_->publish_state(value); - } -#endif -} - -void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { - if (len < 1) - return; -#ifdef USE_SELECT - if (this->trigger_speed_select_ == nullptr) - return; - uint8_t value = data[0]; - if (value <= 2) { - this->trigger_speed_select_->publish_state(value); - } -#endif -} - -void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { - if (len < 8) - return; - float z_min = read_f32_le(data); - float z_max = read_f32_le(data + 4); - this->z_min_ = z_min; - this->z_max_ = z_max; -#ifdef USE_NUMBER - this->publish_number_clamped_(this->z_min_number_, z_min); - this->publish_number_clamped_(this->z_max_number_, z_max); -#endif -} - -void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { - if (len < 1) - return; -#ifdef USE_SELECT - if (this->installation_select_ == nullptr) - return; - uint8_t value = data[0]; - if (value <= 1) { - this->installation_select_->publish_state(value); - } -#endif -} - void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; @@ -927,15 +89,6 @@ void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t le this->update_work_mode_fallback_(); } -void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { - if (len < 4) - return; -#ifdef USE_NUMBER - uint32_t sleep_ms = encode_value_le(data); - this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); -#endif -} - void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; @@ -961,10 +114,7 @@ void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t le void LD6002BComponent::update_work_mode_fallback_() { #ifdef USE_TEXT_SENSOR - if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) { - return; - } - if (!this->low_power_reported_) { + if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_ || !this->low_power_reported_) { return; } // Target presence is only meaningful while the stream that maintains it runs. @@ -980,325 +130,36 @@ void LD6002BComponent::publish_work_mode_(bool low_power) { if (this->work_mode_text_sensor_ == nullptr) { return; } - if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) { - return; + if (this->work_mode_low_power_.next(low_power)) { + this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); } - this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); - this->last_work_mode_valid_ = true; - this->last_work_mode_low_power_ = low_power; #endif } -#ifdef USE_NUMBER -void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { - if (number == nullptr) - return; - if (std::isnan(value)) { - // NAN is this component's "the module has not told us yet". Publishing it on an - // entity that has never had a state would report a nan where unknown is the - // truth; on one that already shows a value it is the only way to say that value - // no longer describes the selected area. - if (number->has_state()) { - number->publish_state(value); - } - return; - } - const float min_value = number->traits.get_min_value(); - const float max_value = number->traits.get_max_value(); - // Outside the declared range the user cannot write the value back, so publish - // what they can reach and say what the module actually sent. - if (value < min_value || value > max_value) { - ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", LOG_STR_ARG(number->get_log_name()), value, - min_value, max_value); - value = std::clamp(value, min_value, max_value); - } - number->publish_state(value); -} -#endif - -void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) { - if (len < 4) - return; -#ifdef USE_TEXT_SENSOR - if (this->ota_version_text_sensor_ == nullptr) - return; - uint8_t project = data[0]; - uint8_t major = data[1]; - uint8_t minor = data[2]; - uint8_t patch = data[3]; - char buf[32]; - if (project == 0) { - std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); - } else { - std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); - } - this->ota_version_text_sensor_->publish_state(buf); - this->save_version_pref_(buf); -#endif -} - -bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { - if (len > CMD_MAX_DATA_LEN) { - ESP_LOGW(TAG, "Command data too large: %u", len); - return false; - } - if (this->cmd_queue_.size() >= CMD_QUEUE_SIZE) { - ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); - return false; - } - - PendingCommand cmd; - cmd.type = type; - cmd.len = len; - if (len > 0 && data != nullptr) { - std::memcpy(cmd.data.data(), data, len); - } - this->cmd_queue_.push(cmd); - - this->process_command_queue_(); - return true; -} - -void LD6002BComponent::process_command_queue_() { - uint32_t now = millis(); - if (this->command_active_) { - // A sleeping module consumes the opening attempt as its wake-up instead of answering it. - const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS; - if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { - const uint32_t active_control_command = - (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? encode_value_le(this->active_command_.data.data()) - : 0; - if (this->retries_left_ > 0) { -#ifdef ESPHOME_LOG_HAS_VERBOSE - if (active_control_command != 0) { - ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining", - control_command_name(active_control_command), active_control_command, this->retries_left_); - } else { - // Writes without a control subcommand (hold delay, z-range) had no retry trace at all. - ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type, - this->retries_left_); - } -#endif - this->command_sent_ = false; - this->last_send_ms_ = 0; - this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); - this->retries_left_--; - } else { - if (active_control_command != 0) { - ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type, - active_control_command); - } else { - ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); - } - if (this->active_command_.type == TYPE_SET_AREA) { - this->area_write_in_flight_ = false; - } - // The deferred apply is waiting on the report this command would have - // brought back, and nothing else re-arms it. Dropping it here is the - // difference between one apply lost to a timeout and one that rides in on - // an unrelated area report later, writing bounds the user has moved on from. - if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { - this->deferred_apply_pending_ = false; - this->restore_deferred_edits_(); - ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); - } - // A reply may still be in flight for the attempt we just gave up on, so carry one over as - // debt rather than clearing the ledger, or that late ACK would retire the successor. Only - // one: reaching this point means nothing was answered at all, so the older attempts are - // speculative, and carrying them would swallow the successor's own replies. - const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) + - (this->acks_expected_ > 0 ? 1 : 0); - this->stale_ack_type_ = this->active_command_.type; - this->stale_ack_count_ = static_cast(std::min(owed, 255)); - this->stale_ack_ms_ = now; - this->send_generation_++; - this->command_active_ = false; - this->command_sent_ = false; - this->last_send_ms_ = 0; - } - } - return; - } - - if (this->cmd_queue_.empty()) - return; - - this->active_command_ = this->cmd_queue_.front(); - this->cmd_queue_.pop(); - - this->send_generation_++; - this->retries_left_ = CMD_MAX_RETRIES; - this->command_active_ = true; - this->command_sent_ = false; - this->last_send_ms_ = 0; - this->attempts_sent_ = 0; - this->acks_expected_ = 0; - if (this->stale_ack_type_ != this->active_command_.type) { - this->stale_ack_count_ = 0; - } - this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); -} - -void LD6002BComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) { - this->send_command_internal_(type, data, len, true); -} - -void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { - if (len > CMD_MAX_DATA_LEN) { - ESP_LOGW(TAG, "Command data too large: %u", len); - if (track) { - // Release the slot: an unwritten command is never acked and never times out. - this->command_active_ = false; - this->command_sent_ = false; - this->last_send_ms_ = 0; - } - return; - } - +bool LD6002BComponent::defer_frame_write(uint16_t type, const uint8_t *data, uint8_t len, bool track) { // Anonymous timeouts never replace each other; with a pulse already pending the module is waking anyway. - if (this->auto_wake_ && this->wakeup_pin_ != nullptr && !this->wake_pulse_pending_) { - // Snapshot the payload: the deferred write must not depend on state a completing command changes. - if (len > 0 && data != nullptr) { - std::memcpy(this->wake_scratch_.data(), data, len); - } - // A button pulse must not raise the pin in the middle of this one. - this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); - this->wake_pulse_pending_ = true; - this->wakeup_pin_->digital_write(false); - const uint8_t generation = this->send_generation_; - this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { - this->wakeup_pin_->digital_write(true); - this->wake_pulse_pending_ = false; - // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been - // retired must not transmit: the frame would land after its successor and be booked to it. - if (track && generation != this->send_generation_) { - return; - } - this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); - }); - return; - } - - this->write_frame_(type, data, len, track); -} - -void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { - uint16_t frame_id = this->next_frame_id_++ & 0x7FFF; - frame_id |= 0x8000; - - uint8_t header_xor = 0; - auto write_header = [&](uint8_t b) { - this->write_byte(b); - header_xor ^= b; - }; - - write_header(TF_SOF); - write_header((frame_id >> 8) & 0xFF); - write_header(frame_id & 0xFF); - write_header((len >> 8) & 0xFF); - write_header(len & 0xFF); - write_header((type >> 8) & 0xFF); - write_header(type & 0xFF); - - this->write_byte(static_cast(~header_xor)); - + if (!this->auto_wake_ || this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) + return false; + // Snapshot the payload: the deferred write must not depend on state a completing command changes. if (len > 0 && data != nullptr) { - this->write_array(data, len); - this->write_byte(static_cast(~xor_bytes(data, len))); + std::memcpy(this->wake_scratch_.data(), data, len); } - const uint32_t now = millis(); - if (track) { - // A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered. - if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) { - this->acks_expected_++; - } - this->last_send_ms_ = now; - this->command_sent_ = true; - this->attempts_sent_++; - } - this->last_traffic_ms_ = now; -} - -bool LD6002BComponent::send_control_command_(uint32_t command) { - uint8_t data[4]; - write_u32_le(data, command); - return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); -} - -void LD6002BComponent::send_z_range_() { - // One frame carries both bounds, so half a range cannot be written. - if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { - ESP_LOGW(TAG, "Z range not written, other bound unknown"); - return; - } - // Both bounds are known and crossed; the frame has no way to say that. - if (this->z_min_ > this->z_max_) { - ESP_LOGW(TAG, "Z range not written, min above max"); - return; - } - uint8_t data[8]; - write_f32_le(data, this->z_min_); - write_f32_le(data + 4, this->z_max_); - this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); -} - -void LD6002BComponent::apply_area_config_() { - if (!this->area_id_set_) { - ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); - return; - } - if (this->area_id_ >= AREA_ID_COUNT) { - ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); - return; - } - - const bool interference = this->area_id_ < AREA_COUNT; - const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); - AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; - if (!std::isnan(this->area_x_min_)) - desired.x_min = this->area_x_min_; - if (!std::isnan(this->area_x_max_)) - desired.x_max = this->area_x_max_; - if (!std::isnan(this->area_y_min_)) - desired.y_min = this->area_y_min_; - if (!std::isnan(this->area_y_max_)) - desired.y_max = this->area_y_max_; - if (!std::isnan(this->area_z_min_)) - desired.z_min = this->area_z_min_; - if (!std::isnan(this->area_z_max_)) - desired.z_max = this->area_z_max_; - - if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || - std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { - // Ask first: a read that never reached the queue would leave a deferral waiting - // on a report nobody requested, with the user's values already retired for it. - if (!this->send_control_command_(CMD_GET_AREAS)) { - ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + // A button pulse must not raise the pin in the middle of this one. + this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); + this->wake_pulse_pending_ = true; + this->wakeup_pin_->digital_write(false); + const uint8_t generation = this->send_generation_; + this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { + this->wakeup_pin_->digital_write(true); + this->wake_pulse_pending_ = false; + // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been + // retired must not transmit: the frame would land after its successor and be booked to it. + if (track && generation != this->send_generation_) { return; } - this->deferred_apply_pending_ = true; - this->pending_area_id_ = this->area_id_; - // The ledger, not the mirror: the mirror also carries whatever the module last - // reported for the axes the user never touched, and staging those would hand them - // back later wearing the user's badge -- a module value the next report is then - // kept away from. Staging only what was actually typed is also what makes the - // replay's overlay right: the untouched axes come from the fresh report. An - // empty ledger is a meaning rather than a gap, then: an apply with nothing - // staged rewrites the area exactly as the report just described it, which is - // what a direct apply with nothing staged already does. - this->pending_area_updates_ = this->area_edits_; - // Staged above, so they are the deferred apply's values now rather than an - // unsent edit. Anything typed from here belongs to whatever the user does - // next, which may well be a different area. - this->area_edits_ = AreaConfig{}; - ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); - return; - } - // Only a write the module will actually see retires them. - if (this->queue_area_config_(this->area_id_, desired)) { - this->area_edits_ = AreaConfig{}; - } + this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); + }); + return true; } void LD6002BComponent::wake_() { @@ -1312,462 +173,27 @@ void LD6002BComponent::wake_() { [this]() { this->wakeup_pin_->digital_write(true); }); } -#ifdef USE_NUMBER -// Each setter picks its setting by entity pointer, so the entities share one generated class. -void LD6002BComponent::set_number_value(number::Number *entity, float value) { - if (entity == this->hold_delay_number_ || entity == this->low_power_sleep_number_) { - uint8_t data[4]; - write_u32_le(data, static_cast(value)); - this->queue_command_(entity == this->hold_delay_number_ ? TYPE_SET_HOLD_DELAY : TYPE_SET_LOW_POWER_SLEEP, data, - sizeof(data)); - } else if (entity == this->z_min_number_) { - this->z_min_ = value; - this->send_z_range_(); - } else if (entity == this->z_max_number_) { - this->z_max_ = value; - this->send_z_range_(); - } else if (entity == this->area_x_min_number_) { - this->area_x_min_ = value; - this->area_edits_.x_min = value; - } else if (entity == this->area_x_max_number_) { - this->area_x_max_ = value; - this->area_edits_.x_max = value; - } else if (entity == this->area_y_min_number_) { - this->area_y_min_ = value; - this->area_edits_.y_min = value; - } else if (entity == this->area_y_max_number_) { - this->area_y_max_ = value; - this->area_edits_.y_max = value; - } else if (entity == this->area_z_min_number_) { - this->area_z_min_ = value; - this->area_edits_.z_min = value; - } else { // Only registered entities call this, so the last one needs no compare - this->area_z_max_ = value; - this->area_edits_.z_max = value; - } -} -#endif - -#ifdef USE_SELECT -void LD6002BComponent::set_select_value(select::Select *entity, size_t index) { - if (entity == this->sensitivity_select_) { - if (index == 0) { - this->send_control_command_(CMD_SENSITIVITY_LOW); - } else if (index == 1) { - this->send_control_command_(CMD_SENSITIVITY_MEDIUM); - } else if (index == 2) { - this->send_control_command_(CMD_SENSITIVITY_HIGH); - } - } else if (entity == this->trigger_speed_select_) { - if (index == 0) { - this->send_control_command_(CMD_TRIGGER_SLOW); - } else if (index == 1) { - this->send_control_command_(CMD_TRIGGER_MEDIUM); - } else if (index == 2) { - this->send_control_command_(CMD_TRIGGER_FAST); - } - } else if (entity == this->installation_select_) { - if (index == 0) { - this->send_control_command_(CMD_INSTALL_TOP); - } else if (index == 1) { - this->send_control_command_(CMD_INSTALL_SIDE); - } - } else { // Only registered entities call this, so the last one needs no compare - this->area_id_ = static_cast(index); - this->area_id_set_ = true; - this->update_area_numbers_for_id_(this->area_id_); - this->save_area_id_pref_(this->area_id_); - } -} -#endif - -void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { - // A report refreshes every axis the user is not in the middle of changing. An - // unapplied edit is the one value here the module cannot know about, so taking - // the report over it would discard what the user typed with nothing to show for it. - const AreaConfig &edits = this->area_edits_; - if (std::isnan(edits.x_min)) - this->area_x_min_ = area.x_min; - if (std::isnan(edits.x_max)) - this->area_x_max_ = area.x_max; - if (std::isnan(edits.y_min)) - this->area_y_min_ = area.y_min; - if (std::isnan(edits.y_max)) - this->area_y_max_ = area.y_max; - if (std::isnan(edits.z_min)) - this->area_z_min_ = area.z_min; - if (std::isnan(edits.z_max)) - this->area_z_max_ = area.z_max; - this->publish_area_numbers_(); -} - -// The mirror, not the report: an axis a report was kept away from has to keep its -// displayed value too, or the entity and the value the next apply sends disagree. -void LD6002BComponent::publish_area_numbers_() { -#ifdef USE_NUMBER - this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); - this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); - this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); - this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); - this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); - this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); -#endif -} - -void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { - if (area_id >= AREA_ID_COUNT) - return; - const bool interference = area_id < AREA_COUNT; - const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); - const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; - // The edits belonged to the area being navigated away from. - this->area_edits_ = AreaConfig{}; - this->update_area_numbers_(area); -} - -bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { - // One frame carries all three pairs and cannot express a crossed one; the module - // would keep a box nothing can ever be inside. Both callers arrive with the six - // bounds resolved, so this is the last place that can say no -- and the return - // value is how saying no reaches the caller, which must not then retire the edits - // the user still has to fix. - if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { - ESP_LOGW(TAG, "Area %u not written, min above max", area_id); - return false; - } - uint8_t data[AREA_CONFIG_LEN]; - write_int32_le(data, static_cast(area_id)); - write_f32_le(data + 4, desired.x_min); - write_f32_le(data + 8, desired.x_max); - write_f32_le(data + 12, desired.y_min); - write_f32_le(data + 16, desired.y_max); - write_f32_le(data + 20, desired.z_min); - write_f32_le(data + 24, desired.z_max); - - if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { - // Nothing is on its way, so the cache must not claim these bounds, the ack - // refresh must not be armed for an ack that cannot come, and the values stay - // the user's unsent edit. - return false; - } - this->area_write_in_flight_ = true; - - const bool interference = area_id < AREA_COUNT; - const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); - AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; - store = desired; - // The six numbers show one area at a time, and a deferred apply can land here for - // an area the user has navigated away from. Same question handle_area_report_ - // asks before it touches them. - const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; - if (area_id == selected_id) { - this->update_area_numbers_(store); - } - return true; -} - -void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { - if (!this->deferred_apply_pending_) { - return; - } - if (this->pending_area_id_ >= AREA_ID_COUNT) { - this->deferred_apply_pending_ = false; - return; - } - const bool interference = this->pending_area_id_ < AREA_COUNT; - const uint8_t index = - interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); - AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; - - if (!std::isnan(this->pending_area_updates_.x_min)) - desired.x_min = this->pending_area_updates_.x_min; - if (!std::isnan(this->pending_area_updates_.x_max)) - desired.x_max = this->pending_area_updates_.x_max; - if (!std::isnan(this->pending_area_updates_.y_min)) - desired.y_min = this->pending_area_updates_.y_min; - if (!std::isnan(this->pending_area_updates_.y_max)) - desired.y_max = this->pending_area_updates_.y_max; - if (!std::isnan(this->pending_area_updates_.z_min)) - desired.z_min = this->pending_area_updates_.z_min; - if (!std::isnan(this->pending_area_updates_.z_max)) - desired.z_max = this->pending_area_updates_.z_max; - - if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || - std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { - // Only the report covering this area's half can still fill it in, and there is - // exactly one of those per read. Once it has landed with a bound still unknown, - // nothing further is coming and waiting means waiting forever. - if (reported_interference == interference) { - this->deferred_apply_pending_ = false; - this->restore_deferred_edits_(); - ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); - } - return; - } - - const uint8_t area_id = this->pending_area_id_; - this->deferred_apply_pending_ = false; - if (!this->queue_area_config_(area_id, desired)) { - // Nothing was queued, so this is a drop like the other two: hand the staged - // values back rather than leaving them with no ledger to protect them. - this->restore_deferred_edits_(); - } -} - -void LD6002BComponent::init_area_id_pref_() { -#ifdef USE_SELECT - if (this->area_id_select_ == nullptr) { - return; - } - this->area_id_pref_ = this->area_id_select_->make_entity_preference(); - this->area_id_pref_initialized_ = true; - - uint8_t value = 0; - if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { - // No stored selection. The numbers are about to display this area either way, - // so select it for real: a displayed area that apply_area then refuses to write - // is the one combination the user cannot make sense of. - value = AREA_ID_DEFAULT; - } - this->area_id_select_->publish_state(value); - this->area_id_ = value; - this->area_id_set_ = true; - this->update_area_numbers_for_id_(value); -#endif -} - -void LD6002BComponent::save_area_id_pref_(uint8_t value) { -#ifdef USE_SELECT - if (!this->area_id_pref_initialized_) { - return; - } - this->area_id_pref_.save(&value); -#endif -} - -void LD6002BComponent::init_version_pref_() { -#ifdef USE_TEXT_SENSOR - if (this->ota_version_text_sensor_ == nullptr) { - return; - } - this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); - this->version_pref_initialized_ = true; - - VersionPref pref{}; - if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { - pref.value[sizeof(pref.value) - 1] = '\0'; - this->ota_version_text_sensor_->publish_state(pref.value); - } -#endif -} - -void LD6002BComponent::save_version_pref_(const char *value) { -#ifdef USE_TEXT_SENSOR - if (!this->version_pref_initialized_) { - return; - } - VersionPref pref{}; - std::strncpy(pref.value, value, sizeof(pref.value) - 1); - pref.value[sizeof(pref.value) - 1] = '\0'; - this->version_pref_.save(&pref); -#endif -} - -#ifdef USE_SENSOR -void LD6002BComponent::clear_target_slot_(uint8_t index) { - if (!this->last_target_presence_[index]) { - return; - } - TargetSensors &target = this->targets_[index]; - PUBLISH_STATE(target.x, NAN); - PUBLISH_STATE(target.y, NAN); - PUBLISH_STATE(target.z, NAN); - PUBLISH_STATE(target.dop_idx, NAN); - PUBLISH_STATE(target.cluster_id, NAN); - // The slot is free: the next person's id is new even when it repeats this one. - this->last_cluster_id_valid_[index] = false; -} -#endif - -void LD6002BComponent::restore_deferred_edits_() { - // The staged values become an unsent edit again, but only for the user who is - // still looking at the area they were staged for; anyone else's ledger belongs to - // the area they are on now. - const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; - if (this->pending_area_id_ != selected_id) { - return; - } - // Axis by axis rather than a whole-struct assignment: the user can have edited - // another bound while the deferral was in flight, and that edit is newer than - // anything the deferral staged. Assigning over the ledger would drop it back to - // NaN and let the next report take the value away. A live edit wins; only an axis - // with nothing in the ledger takes its staged value back. - // - // The mirror moves with the ledger, because on the report path handle_area_report_ - // ran update_area_numbers_ before the replay, with the ledger still empty -- so the - // mirror already holds the module's bounds and both the entities and the next apply - // would build on them. On the timeout path no report arrived, the mirror still - // holds the staged values, and this is an identity. - const AreaConfig &staged = this->pending_area_updates_; - if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { - this->area_edits_.x_min = staged.x_min; - this->area_x_min_ = staged.x_min; - } - if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { - this->area_edits_.x_max = staged.x_max; - this->area_x_max_ = staged.x_max; - } - if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { - this->area_edits_.y_min = staged.y_min; - this->area_y_min_ = staged.y_min; - } - if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { - this->area_edits_.y_max = staged.y_max; - this->area_y_max_ = staged.y_max; - } - if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { - this->area_edits_.z_min = staged.z_min; - this->area_z_min_ = staged.z_min; - } - if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { - this->area_edits_.z_max = staged.z_max; - this->area_z_max_ = staged.z_max; - } - this->publish_area_numbers_(); -} - -void LD6002BComponent::clear_area_presence_() { - if (!this->area_presence_any_) { - return; - } - // Nothing else corrects this: 0x0A0A carries no period the protocol states and no - // command stops it, so the module going unattended is the only moment the - // component can know a stored "occupied" has stopped being true. - this->area_presence_any_ = false; -#ifdef USE_BINARY_SENSOR - for (uint8_t i = 0; i < AREA_COUNT; i++) { - PUBLISH_STATE(this->area_presence_[i], false); - } - const bool presence = this->target_presence_any_ || this->area_presence_any_; - PUBLISH_STATE(this->presence_binary_sensor_, presence); -#endif -} - -void LD6002BComponent::clear_target_state_() { - // Nothing corrects any of this until the stream comes back. The slot table goes - // with it: slots key on cluster ids, which only track a person while reports are - // arriving, and the room can empty and refill across the gap -- so the next - // report starts from an empty table and fills slots in wire order, rather than - // handing one back to whoever last held that id. - for (uint8_t i = 0; i < MAX_TARGETS; i++) { -#ifdef USE_SENSOR - this->clear_target_slot_(i); - this->last_target_presence_[i] = false; -#endif - if (this->slot_occupied_[i]) { - this->slot_occupied_[i] = false; -#ifdef USE_BINARY_SENSOR - PUBLISH_STATE(this->target_presence_[i], false); -#endif - } - } -#ifdef USE_SENSOR - if (this->last_target_count_ != 0xFFFFFFFF) { - PUBLISH_STATE(this->target_count_sensor_, NAN); - this->last_target_count_ = 0xFFFFFFFF; - } -#endif - if (this->target_presence_any_) { - this->target_presence_any_ = false; -#ifdef USE_BINARY_SENSOR - bool presence = this->target_presence_any_ || this->area_presence_any_; - PUBLISH_STATE(this->presence_binary_sensor_, presence); -#endif - this->update_work_mode_fallback_(); - } -} - #ifdef USE_SWITCH void LD6002BComponent::set_switch_state(switch_::Switch *entity, bool state) { - if (entity == this->low_power_switch_) { - 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_(); - } else if (entity == this->point_cloud_switch_) { - 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 - // new again when the stream comes back. - if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) { - this->point_count_sensor_->publish_state(NAN); - this->last_point_count_ = 0xFFFFFFFF; - } -#endif - } else { // Only registered entities call this, so the last one needs no compare - 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_(); - } + if (entity != this->low_power_switch_) { + this->LD600XComponent::set_switch_state(entity, state); + return; } + 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_(); } #endif -void LD6002BComponent::press_button(ButtonType type) { - switch (type) { - case ButtonType::APPLY_AREA: - this->apply_area_config_(); - break; - case ButtonType::AUTO_INTERFERENCE: - this->send_control_command_(CMD_AUTO_INTERFERENCE); - // The module recomputes the interference areas without reporting them. - this->send_control_command_(CMD_GET_AREAS); - break; - case ButtonType::GET_AREAS: - this->send_control_command_(CMD_GET_AREAS); - break; - case ButtonType::CLEAR_INTERFERENCE: - this->send_control_command_(CMD_CLEAR_INTERFERENCE); - // The module rewrites the areas but does not report them, so ask for the new geometry the - // way the apply_area ack path does; the queue keeps it behind the command above. - this->send_control_command_(CMD_GET_AREAS); - break; - case ButtonType::RESET_DETECTION_AREA: - this->send_control_command_(CMD_RESET_DETECTION_AREA); - this->send_control_command_(CMD_GET_AREAS); - break; - case ButtonType::GET_DELAY: - this->send_control_command_(CMD_GET_DELAY); - break; - case ButtonType::GET_SENSITIVITY: - this->send_control_command_(CMD_GET_SENSITIVITY); - break; - case ButtonType::GET_TRIGGER_SPEED: - this->send_control_command_(CMD_GET_TRIGGER); - break; - case ButtonType::GET_Z_RANGE: - this->send_control_command_(CMD_GET_Z_RANGE); - break; - case ButtonType::GET_INSTALLATION: - this->send_control_command_(CMD_GET_INSTALLATION); - break; - case ButtonType::GET_LOW_POWER_MODE: - this->send_control_command_(CMD_GET_LOW_POWER); - break; - case ButtonType::GET_LOW_POWER_SLEEP_TIME: - this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); - break; - case ButtonType::RESET_UNATTENDED: - this->send_control_command_(CMD_RESET_UNATTENDED); - break; - case ButtonType::WAKE: - this->wake_(); - break; +#ifdef USE_BUTTON +void LD6002BComponent::press_button(uint8_t kind) { + if (kind == BUTTON_WAKE) { + this->wake_(); + } else { + this->LD600XComponent::press_button(kind); } } +#endif } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 9acb8ca478f..74c45c7a28e 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -1,450 +1,60 @@ #pragma once -#include "esphome/core/defines.h" -#include "esphome/core/component.h" -#include "esphome/core/helpers.h" -#include "esphome/core/preferences.h" +#include "esphome/components/ld600x/ld600x.h" #include "esphome/core/gpio.h" -#include "esphome/components/uart/uart.h" -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif - -#include -#include namespace esphome::ld6002b { -static constexpr uint8_t MAX_TARGETS = 3; -static constexpr uint8_t AREA_COUNT = 4; -// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so -// this is the whole id space TYPE_SET_AREA accepts. -static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; -static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; -static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; -// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. -static constexpr size_t CMD_MAX_DATA_LEN = 28; - -enum class ButtonType : uint8_t { - APPLY_AREA, - AUTO_INTERFERENCE, - GET_AREAS, - CLEAR_INTERFERENCE, - RESET_DETECTION_AREA, - GET_DELAY, - GET_SENSITIVITY, - GET_TRIGGER_SPEED, - GET_Z_RANGE, - GET_INSTALLATION, - GET_LOW_POWER_MODE, - GET_LOW_POWER_SLEEP_TIME, - RESET_UNATTENDED, - WAKE, +enum LD6002BButtonType : uint8_t { + BUTTON_WAKE = ld600x::LD600X_MODEL_KIND_BASE, }; -#ifdef USE_SENSOR -struct TargetSensors { - sensor::Sensor *x{nullptr}; - sensor::Sensor *y{nullptr}; - sensor::Sensor *z{nullptr}; - sensor::Sensor *dop_idx{nullptr}; - sensor::Sensor *cluster_id{nullptr}; -}; - -struct AreaSensors { - sensor::Sensor *x_min{nullptr}; - sensor::Sensor *x_max{nullptr}; - sensor::Sensor *y_min{nullptr}; - sensor::Sensor *y_max{nullptr}; - sensor::Sensor *z_min{nullptr}; - sensor::Sensor *z_max{nullptr}; -}; -#endif - -struct AreaConfig { - float x_min{NAN}; - float x_max{NAN}; - float y_min{NAN}; - float y_max{NAN}; - float z_min{NAN}; - float z_max{NAN}; -}; - -struct VersionPref { - char value[20]; -}; - -class LD6002BComponent : public Component, public uart::UARTDevice { +class LD6002BComponent final : public ld600x::LD600XComponent { public: + LD6002BComponent() : LD600XComponent(LOG_STR("ld6002b")) {} + void setup() override; - void loop() override; - void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; } void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; } void set_auto_wake(bool enable) { this->auto_wake_ = enable; } -#ifdef USE_SENSOR - SUB_SENSOR(target_count) - SUB_SENSOR(point_count) - - void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->targets_[target].x = sensor; - } - void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->targets_[target].y = sensor; - } - void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->targets_[target].z = sensor; - } - void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->targets_[target].dop_idx = sensor; - } - void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->targets_[target].cluster_id = sensor; - } - void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].x_min = sensor; - } - void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].x_max = sensor; - } - void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].y_min = sensor; - } - void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].y_max = sensor; - } - void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].z_min = sensor; - } - void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->interference_areas_[area].z_max = sensor; - } - - void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].x_min = sensor; - } - void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].x_max = sensor; - } - void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].y_min = sensor; - } - void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].y_max = sensor; - } - void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].z_min = sensor; - } - void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { - if (area >= AREA_COUNT) - return; - this->detection_areas_[area].z_max = sensor; - } -#endif - -#ifdef USE_BINARY_SENSOR - SUB_BINARY_SENSOR(presence) - void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { - if (target >= MAX_TARGETS) - return; - this->target_presence_[target] = sensor; - } - void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { - if (area >= AREA_COUNT) - return; - this->area_presence_[area] = sensor; - } -#endif - -#ifdef USE_TEXT_SENSOR - SUB_TEXT_SENSOR(work_mode) - SUB_TEXT_SENSOR(ota_version) -#endif - -#ifdef USE_NUMBER - SUB_NUMBER(hold_delay) - SUB_NUMBER(z_min) - SUB_NUMBER(z_max) - SUB_NUMBER(low_power_sleep) - - SUB_NUMBER(area_x_min) - SUB_NUMBER(area_x_max) - SUB_NUMBER(area_y_min) - SUB_NUMBER(area_y_max) - SUB_NUMBER(area_z_min) - SUB_NUMBER(area_z_max) -#endif - -#ifdef USE_SELECT - SUB_SELECT(sensitivity) - SUB_SELECT(trigger_speed) - SUB_SELECT(installation) - SUB_SELECT(area_id) -#endif - #ifdef USE_SWITCH SUB_SWITCH(low_power) - SUB_SWITCH(point_cloud) - SUB_SWITCH(target_display) + void set_switch_state(switch_::Switch *entity, bool state) override; #endif - -#ifdef USE_NUMBER - void set_number_value(number::Number *entity, float value); +#ifdef USE_BUTTON + void press_button(uint8_t kind) override; #endif -#ifdef USE_SELECT - void set_select_value(select::Select *entity, size_t index); -#endif -#ifdef USE_SWITCH - void set_switch_state(switch_::Switch *entity, bool state); -#endif - void press_button(ButtonType type); protected: - enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; + bool handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) override; + bool model_wants_target_stream() const override; + void on_presence_update() override { this->update_work_mode_fallback_(); } + bool defer_frame_write(uint16_t type, const uint8_t *data, uint8_t len, bool track) override; + void setup_model() override; + void dump_model_config() override; - struct PendingCommand { - uint16_t type{0}; - uint8_t len{0}; - std::array data{}; - }; - - void parse_byte_(uint8_t byte); - void reset_parser_(); - void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); - void handle_target_report_(const uint8_t *data, uint16_t len); - void handle_point_cloud_(const uint8_t *data, uint16_t len); - void handle_area_presence_(const uint8_t *data, uint16_t len); - void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); - void handle_delay_report_(const uint8_t *data, uint16_t len); - void handle_sensitivity_report_(const uint8_t *data, uint16_t len); - void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); - void handle_z_range_report_(const uint8_t *data, uint16_t len); - void handle_installation_report_(const uint8_t *data, uint16_t len); void handle_low_power_report_(const uint8_t *data, uint16_t len); - void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); void handle_work_mode_report_(const uint8_t *data, uint16_t len); - void handle_version_report_(const uint8_t *data, uint16_t len); void update_work_mode_fallback_(); void publish_work_mode_(bool low_power); - // Drops every target-derived reading and the slot table they are indexed by. - void clear_target_state_(); - void clear_area_presence_(); - void restore_deferred_edits_(); - void publish_area_numbers_(); -#ifdef USE_SENSOR - void clear_target_slot_(uint8_t index); -#endif -#ifdef USE_NUMBER - void publish_number_clamped_(number::Number *number, float value); -#endif - void update_area_numbers_(const AreaConfig &area); - void update_area_numbers_for_id_(uint8_t area_id); - bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); - void try_apply_pending_area_(bool reported_interference); - void init_area_id_pref_(); - void save_area_id_pref_(uint8_t value); - void init_version_pref_(); - void save_version_pref_(const char *value); - - // Returns whether the command was queued: it is dropped, with a log line, when - // the payload is too long or the ring is full. - bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); - void process_command_queue_(); - void send_command_(uint16_t type, const uint8_t *data, uint8_t len); - void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); - void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); - // Returns whether the command reached the queue; see queue_command_. - bool send_control_command_(uint32_t command); - void send_z_range_(); - void apply_area_config_(); void wake_(); - static float read_f32_le(const uint8_t *data); - static void write_u32_le(uint8_t *data, uint32_t value); - static void write_int32_le(uint8_t *data, int32_t value); - static void write_f32_le(uint8_t *data, float value); - -#ifdef USE_SENSOR - std::array targets_{}; - std::array interference_areas_{}; - std::array detection_areas_{}; -#endif -#ifdef USE_BINARY_SENSOR - std::array target_presence_{}; - std::array area_presence_{}; -#endif -#ifdef USE_TEXT_SENSOR - ESPPreferenceObject version_pref_{}; - bool version_pref_initialized_{false}; -#endif -#ifdef USE_SELECT - ESPPreferenceObject area_id_pref_{}; - bool area_id_pref_initialized_{false}; -#endif + // Keyed so a repeated press replaces its own pending timeout instead of stacking + // another, and so the command path can cancel it when it takes the pin over. + static constexpr uint32_t WAKE_BUTTON_TIMEOUT_ID = MODEL_TIMEOUT_ID; GPIOPin *wakeup_pin_{nullptr}; uint32_t wakeup_pulse_ms_{50}; - bool auto_wake_{true}; - - ParseState parse_state_{ParseState::SOF}; - uint8_t header_pos_{0}; - uint8_t header_xor_{0}; - uint16_t data_len_{0}; - uint16_t frame_type_{0}; - uint16_t frame_id_{0}; - uint16_t data_pos_{0}; - uint8_t data_xor_{0}; - uint32_t discard_remaining_{0}; - bool frame_oversize_{false}; - size_t max_data_len_{0}; - uint8_t *data_buf_{nullptr}; - uint16_t next_frame_id_{0}; - - // Sized for the two bursts that reach it, both counted as what is still queued - // once the first command is dequeued: boot leaves 11 with every platform - // configured, and pressing all fourteen buttons before an ack lands leaves 15. - // Neither overflowed 16, but one free slot is not headroom, and overflowing is a - // dropped command with only a log line to show for it. Costs 256 bytes more per - // configured instance, and this component is MULTI_CONF. - static constexpr uint8_t CMD_QUEUE_SIZE = 24; - static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; - // A sleeping module consumes the first frame to wake and answers only the one after it. - static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; - // How long the module stays awake after any frame, and so still answers the next one. - static constexpr uint32_t MODULE_AWAKE_MS = 10000; - static constexpr uint8_t CMD_MAX_RETRIES = 3; - // Keyed so a repeated press replaces its own pending timeout instead of stacking - // another, and so the command path can cancel it when it takes the pin over. - static constexpr uint32_t WAKE_BUTTON_TIMEOUT_ID = 0; - // Keyed so a burst of writes collapses to one read once they settle, rather than - // one read per write. - static constexpr uint32_t AREA_REFRESH_TIMEOUT_ID = 1; - // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. - static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; - - StaticRingBuffer cmd_queue_; - bool command_active_{false}; - bool command_sent_{false}; - PendingCommand active_command_{}; // Frame a pending wake pulse will write, snapshotted because active_command_ may move on first. - std::array wake_scratch_{}; + std::array wake_scratch_{}; + bool auto_wake_{true}; bool wake_pulse_pending_{false}; - uint8_t retries_left_{0}; - uint32_t last_send_ms_{0}; - // Last frame seen in either direction; any traffic keeps the module awake. - uint32_t last_traffic_ms_{0}; - // Frames transmitted for the command in flight, including retries; drives the retry budget. - uint8_t attempts_sent_{0}; - // Subset of those the module can actually answer: a frame that woke it is consumed, not replied to. - uint8_t acks_expected_{0}; - // ACKs still owed for superseded attempts; they carry no id, only their arrival order. - uint16_t stale_ack_type_{0}; - uint8_t stale_ack_count_{0}; - // When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK. - uint32_t stale_ack_ms_{0}; - // Bumped whenever the active command changes, so a deferred send can tell it was retired. - uint8_t send_generation_{0}; - - float z_min_{NAN}; - float z_max_{NAN}; - float area_x_min_{NAN}; - float area_x_max_{NAN}; - float area_y_min_{NAN}; - float area_y_max_{NAN}; - float area_z_min_{NAN}; - float area_z_max_{NAN}; - // What the user has typed and not yet applied; NaN per axis means "nothing of - // mine here, take the module's value". Same sentinel shape as - // pending_area_updates_. Exactly two things empty it: the area_id select moving - // to another area, and an apply that was accepted. A write the bounds guard - // refused leaves it alone, and a deferred apply that had to be dropped hands its - // staged values back here -- but only while the user is still on the area they - // were staged for. Either way the values stay the user's to fix. - AreaConfig area_edits_{}; - std::array interference_area_values_{}; - std::array detection_area_values_{}; - uint8_t area_id_{0xFF}; - bool area_id_set_{false}; - - // Which person owns each target_N slot, so a slot survives the module re-sorting its array. - std::array slot_cluster_{}; - std::array slot_occupied_{}; - - bool target_presence_any_{false}; - // What the switches and setup asked the module for, which is not the same as - // what it is doing yet: a stream keeps sending until it acts on the command. - // The report handlers read these and drop anything a stopped stream still emits. - bool target_display_enabled_{false}; - bool point_cloud_enabled_{false}; - bool area_presence_any_{false}; - bool area_write_in_flight_{false}; bool work_mode_reported_{false}; bool low_power_enabled_{false}; bool low_power_reported_{false}; - bool deferred_apply_pending_{false}; - uint8_t pending_area_id_{0xFF}; - AreaConfig pending_area_updates_{}; - bool last_work_mode_valid_{false}; - bool last_work_mode_low_power_{false}; - -#ifdef USE_SENSOR - std::array last_target_presence_{}; // one-shot NAN clear for target sensors - // A cluster id names a person, so like the counts it is published on change, not per frame. - std::array last_cluster_id_{}; - std::array last_cluster_id_valid_{}; - uint32_t last_target_count_{0xFFFFFFFF}; - uint32_t last_point_count_{0xFFFFFFFF}; -#endif + Deduplicator work_mode_low_power_; }; } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index e2024cdd2f3..9c4a5b8ca6f 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -1,170 +1,14 @@ -import esphome.codegen as cg -from esphome.components import number -from esphome.components.button import DOMAIN as BUTTON_DOMAIN -from esphome.components.select import DOMAIN as SELECT_DOMAIN -import esphome.config_validation as cv -from esphome.const import ( - CONF_AREA_ID, - DEVICE_CLASS_DISTANCE, - DEVICE_CLASS_DURATION, - ENTITY_CATEGORY_CONFIG, - UNIT_METER, - UNIT_MILLISECOND, - UNIT_SECOND, -) -import esphome.final_validate as fv +from esphome.components.ld600x import entities from esphome.types import ConfigType -from .. import LD6002BComponent, validate_unique_settings -from ..const import ( - CONF_APPLY_AREA, - CONF_AREA_CONFIG, - CONF_HOLD_DELAY, - CONF_LD6002B_ID, - CONF_LOW_POWER_SLEEP_TIME, - CONF_Z_MAX, - CONF_Z_MIN, - KEY_X_MAX, - KEY_X_MIN, - KEY_Y_MAX, - KEY_Y_MIN, -) +from .. import LD6002BComponent +from ..const import CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_HOLD_DELAY): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_SECOND, - device_class=DEVICE_CLASS_DURATION, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_Z_MIN): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_Z_MAX): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_MILLISECOND, - device_class=DEVICE_CLASS_DURATION, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_AREA_CONFIG): cv.Schema( - { - cv.Optional(KEY_X_MIN): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(KEY_X_MAX): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(KEY_Y_MIN): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(KEY_Y_MAX): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_Z_MIN): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_Z_MAX): number.number_schema( - number.ApplyNumber, - unit_of_measurement=UNIT_METER, - device_class=DEVICE_CLASS_DISTANCE, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - } - ), - } -) - - -def final_validate(config: ConfigType) -> None: - validate_unique_settings(number.DOMAIN, config) - if config.get(CONF_AREA_CONFIG) is None: - return - - full_config = fv.full_config.get() - hub_id = config[CONF_LD6002B_ID] - - has_apply_area = any( - entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None - for entry in full_config.get(BUTTON_DOMAIN, []) - ) - if not has_apply_area: - raise cv.Invalid( - f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", - path=[CONF_AREA_CONFIG], - ) - - has_area_id_select = any( - entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get(SELECT_DOMAIN, []) - ) - if not has_area_id_select: - raise cv.Invalid( - f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", - path=[CONF_AREA_CONFIG], - ) - - -FINAL_VALIDATE_SCHEMA = final_validate +CONFIG_SCHEMA = entities.number_schema(LD6002BComponent, CONF_LD6002B_ID) +FINAL_VALIDATE_SCHEMA = entities.number_final_validate(CONF_LD6002B_ID, "ld6002b") async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - control = {"apply": "set_number_value(entity, value)", "publish": "before"} - numbers = number.sub_numbers(config, parent=hub) - await numbers( - CONF_HOLD_DELAY, - hub.set_hold_delay_number, - min_value=0, - max_value=65535, - step=1, - **control, - ) - meters = {"min_value": -10, "max_value": 10, "step": 0.1, **control} - await numbers(CONF_Z_MIN, hub.set_z_min_number, **meters) - await numbers(CONF_Z_MAX, hub.set_z_max_number, **meters) - # 0x0205 is a uint32 of ms with no documented maximum; cap it at a minute - await numbers( - CONF_LOW_POWER_SLEEP_TIME, - hub.set_low_power_sleep_number, - min_value=0, - max_value=60000, - step=100, - **control, - ) - - if area_config := config.get(CONF_AREA_CONFIG): - area = number.sub_numbers(area_config, parent=hub) - await area(KEY_X_MIN, hub.set_area_x_min_number, **meters) - await area(KEY_X_MAX, hub.set_area_x_max_number, **meters) - await area(KEY_Y_MIN, hub.set_area_y_min_number, **meters) - await area(KEY_Y_MAX, hub.set_area_y_max_number, **meters) - await area(CONF_Z_MIN, hub.set_area_z_min_number, **meters) - await area(CONF_Z_MAX, hub.set_area_z_max_number, **meters) + await entities.number_to_code(config, CONF_LD6002B_ID) diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 18a9f997f2e..573759035d6 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -1,71 +1,17 @@ -from functools import partial - -import esphome.codegen as cg from esphome.components import select -import esphome.config_validation as cv -from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.components.ld600x import entities from esphome.types import ConfigType -from .. import LD6002BComponent, validate_unique_settings -from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED +from .. import LD6002BComponent +from ..const import AREA_KINDS, CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] -AREA_ID_OPTIONS = [ - "interference_area_0", - "interference_area_1", - "interference_area_2", - "interference_area_3", - "detection_area_0", - "detection_area_1", - "detection_area_2", - "detection_area_3", -] - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_SENSITIVITY): select.select_schema( - select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( - select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( - select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG - ), - cv.Optional(CONF_AREA_ID): select.select_schema( - select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG - ), - } +CONFIG_SCHEMA = entities.select_schema(LD6002BComponent, CONF_LD6002B_ID) +FINAL_VALIDATE_SCHEMA = entities.unique_settings_final_validate( + select.DOMAIN, CONF_LD6002B_ID, "ld6002b" ) -FINAL_VALIDATE_SCHEMA = partial(validate_unique_settings, select.DOMAIN) - - async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - control = {"apply": "set_select_value(entity, index)", "publish": "before"} - selects = select.sub_selects(config, parent=hub) - await selects( - CONF_SENSITIVITY, - hub.set_sensitivity_select, - options=["low", "medium", "high"], - **control, - ) - await selects( - CONF_TRIGGER_SPEED, - hub.set_trigger_speed_select, - options=["slow", "medium", "fast"], - **control, - ) - await selects( - CONF_INSTALLATION_MODE, - hub.set_installation_select, - options=["top", "side"], - **control, - ) - await selects( - CONF_AREA_ID, hub.set_area_id_select, options=AREA_ID_OPTIONS, **control - ) + await entities.select_to_code(config, CONF_LD6002B_ID, area_kinds=AREA_KINDS) diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index d2285187f6b..1d3768a769f 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -1,173 +1,15 @@ -import esphome.codegen as cg -from esphome.components import sensor -from esphome.components.const import CONF_TARGET_COUNT -import esphome.config_validation as cv -from esphome.const import ( - CONF_X, - CONF_Y, - DEVICE_CLASS_DISTANCE, - STATE_CLASS_MEASUREMENT, - UNIT_METER, -) +from esphome.components.ld600x import entities from esphome.types import ConfigType from . import LD6002BComponent -from .const import ( - AREA_COUNT, - CONF_CLUSTER_ID, - CONF_DOPPLER_INDEX, - CONF_LD6002B_ID, - CONF_POINT_COUNT, - CONF_Z, - CONF_Z_MAX, - CONF_Z_MIN, - KEY_X_MAX, - KEY_X_MIN, - KEY_Y_MAX, - KEY_Y_MIN, - MAX_TARGETS, -) +from .const import AREA_KINDS, CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] -TARGET_SCHEMA = cv.Schema( - { - cv.Optional(CONF_X): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - filters=sensor.TIMEOUT_THROTTLE_FILTERS, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_Y): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - filters=sensor.TIMEOUT_THROTTLE_FILTERS, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_Z): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - filters=sensor.TIMEOUT_THROTTLE_FILTERS, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( - accuracy_decimals=0, - filters=sensor.TIMEOUT_THROTTLE_FILTERS, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( - accuracy_decimals=0, - ), - } -) - -AREA_SCHEMA = cv.Schema( - { - cv.Optional(KEY_X_MIN): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(KEY_X_MAX): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(KEY_Y_MIN): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(KEY_Y_MAX): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_Z_MIN): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_Z_MAX): sensor.sensor_schema( - unit_of_measurement=UNIT_METER, - accuracy_decimals=2, - device_class=DEVICE_CLASS_DISTANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - } -) - -# (config key, C++ setter axis) for the six bounds every area sensor block carries. -_AREA_AXES = ( - (KEY_X_MIN, "x_min"), - (KEY_X_MAX, "x_max"), - (KEY_Y_MIN, "y_min"), - (KEY_Y_MAX, "y_max"), - (CONF_Z_MIN, "z_min"), - (CONF_Z_MAX, "z_max"), -) - -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - } - ) - .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) - .extend( - {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} - ) - .extend( - {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} - ) +CONFIG_SCHEMA = entities.sensor_schema( + LD6002BComponent, CONF_LD6002B_ID, area_kinds=AREA_KINDS ) async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - - sensors = sensor.sub_sensors(config) - await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) - await sensors(CONF_POINT_COUNT, hub.set_point_count_sensor) - - for i in range(MAX_TARGETS): - if target_config := config.get(f"target_{i + 1}"): - if x_config := target_config.get(CONF_X): - sens = await sensor.new_sensor(x_config) - cg.add(hub.set_target_x_sensor(i, sens)) - if y_config := target_config.get(CONF_Y): - sens = await sensor.new_sensor(y_config) - cg.add(hub.set_target_y_sensor(i, sens)) - if z_config := target_config.get(CONF_Z): - sens = await sensor.new_sensor(z_config) - cg.add(hub.set_target_z_sensor(i, sens)) - if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX): - sens = await sensor.new_sensor(doppler_index_config) - cg.add(hub.set_target_dop_idx_sensor(i, sens)) - if cluster_id_config := target_config.get(CONF_CLUSTER_ID): - sens = await sensor.new_sensor(cluster_id_config) - cg.add(hub.set_target_cluster_id_sensor(i, sens)) - - for kind in ("interference", "detection"): - for i in range(AREA_COUNT): - if area_config := config.get(f"{kind}_area_{i}"): - for key, axis in _AREA_AXES: - if axis_config := area_config.get(key): - sens = await sensor.new_sensor(axis_config) - cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) + await entities.sensor_to_code(config, CONF_LD6002B_ID, area_kinds=AREA_KINDS) diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py index a6a09cee688..39e791db881 100644 --- a/esphome/components/ld6002b/switch/__init__.py +++ b/esphome/components/ld6002b/switch/__init__.py @@ -1,58 +1,26 @@ -from functools import partial - import esphome.codegen as cg from esphome.components import switch +from esphome.components.ld600x import entities import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import LD6002BComponent, validate_unique_settings -from ..const import ( - CONF_LD6002B_ID, - CONF_LOW_POWER, - CONF_POINT_CLOUD, - CONF_TARGET_DISPLAY, -) +from .. import LD6002BComponent +from ..const import CONF_LD6002B_ID, CONF_LOW_POWER DEPENDENCIES = ["ld6002b"] -# None of these three carry an inversion. They name what the module is doing, not -# how something is wired to it, so an inverted one would only report the opposite -# of the truth -- and the boot restore, which applies a state nothing reports back, -# is where that would be hardest to spot. -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_LOW_POWER): switch.switch_schema( - switch.ApplySwitch, - block_inverted=True, - device_class=DEVICE_CLASS_SWITCH, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_POINT_CLOUD): switch.switch_schema( - switch.ApplySwitch, - block_inverted=True, - device_class=DEVICE_CLASS_SWITCH, - entity_category=ENTITY_CATEGORY_CONFIG, - ), - cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema( - switch.ApplySwitch, - block_inverted=True, - device_class=DEVICE_CLASS_SWITCH, - entity_category=ENTITY_CATEGORY_CONFIG, - default_restore_mode="RESTORE_DEFAULT_ON", - ), - } +CONFIG_SCHEMA = entities.switch_schema( + LD6002BComponent, + CONF_LD6002B_ID, + extra={cv.Optional(CONF_LOW_POWER): entities.CONFIG_SWITCH_SCHEMA}, +) +FINAL_VALIDATE_SCHEMA = entities.unique_settings_final_validate( + switch.DOMAIN, CONF_LD6002B_ID, "ld6002b" ) - - -FINAL_VALIDATE_SCHEMA = partial(validate_unique_settings, switch.DOMAIN) async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - control = {"apply": "set_switch_state(entity, state)", "publish": "after"} switches = switch.sub_switches(config, parent=hub) - await switches(CONF_LOW_POWER, hub.set_low_power_switch, **control) - await switches(CONF_POINT_CLOUD, hub.set_point_cloud_switch, **control) - await switches(CONF_TARGET_DISPLAY, hub.set_target_display_switch, **control) + await switches(CONF_LOW_POWER, hub.set_low_power_switch, **entities.SWITCH_CONTROL) + await entities.switch_to_code(config, CONF_LD6002B_ID) diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index de3dde15cca..b94ab30889c 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -1,29 +1,13 @@ -import esphome.codegen as cg -from esphome.components import text_sensor -import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.components.ld600x import entities from esphome.types import ConfigType from . import LD6002BComponent -from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE +from .const import CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( - entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( - entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ), - } -) +CONFIG_SCHEMA = entities.text_sensor_schema(LD6002BComponent, CONF_LD6002B_ID) async def to_code(config: ConfigType) -> None: - hub = await cg.get_variable(config[CONF_LD6002B_ID]) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_WORK_MODE, hub.set_work_mode_text_sensor) - await text_sensors(CONF_OTA_VERSION, hub.set_ota_version_text_sensor) + await entities.text_sensor_to_code(config, CONF_LD6002B_ID) diff --git a/esphome/components/ld600x/__init__.py b/esphome/components/ld600x/__init__.py new file mode 100644 index 00000000000..edc13b1fbed --- /dev/null +++ b/esphome/components/ld600x/__init__.py @@ -0,0 +1,72 @@ +"""Shared base for the Hi-Link 60 GHz radars that speak the TinyFrame serial protocol. + +Covers the LD6002B and the LD6004. The LD6001A is a different family (AT commands and +another report format) and gets its own component, so it does not build on this one. +""" + +from dataclasses import dataclass, field + +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.cpp_generator import MockObj, MockObjClass + +from .const import MAX_TARGETS + +CODEOWNERS = ["@hepter", "@wolph"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld600x" + +ld600x_ns = cg.esphome_ns.namespace("ld600x") +LD600XComponent = ld600x_ns.class_("LD600XComponent", cg.Component, uart.UARTDevice) +AreaKind = ld600x_ns.enum("AreaKind") +AreaAxis = ld600x_ns.enum("AreaAxis") +ButtonType = ld600x_ns.enum("ButtonType") + +_request_target_slot = cg.slot_counter("LD600X_MAX_TARGETS") +_request_area_kind_slot = cg.slot_counter("LD600X_AREA_KINDS") + + +@dataclass +class LD600XData: + # Area kinds already requested per hub, so repeated requests keep the largest count. + area_kinds: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> LD600XData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = LD600XData() + return CORE.data[DOMAIN] + + +def request_model_sizes(hub: MockObj, *, area_kinds: int) -> None: + """Size the hub's target and area arrays; call it again to raise the area kinds.""" + requested = _get_data().area_kinds + key = str(hub) + if key not in requested: + for _ in range(MAX_TARGETS): + _request_target_slot(key) + for _ in range(requested.get(key, 0), area_kinds): + _request_area_kind_slot(key) + requested[key] = max(requested.get(key, 0), area_kinds) + + +def ld600x_hub_schema(component_class: MockObjClass) -> cv.Schema: + return ( + cv.Schema({cv.GenerateID(): cv.declare_id(component_class)}) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + + +def uart_final_validate(component_name: str) -> cv.Schema: + return uart.final_validate_device_schema( + component_name, + baud_rate=115200, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, + ) diff --git a/esphome/components/ld600x/const.py b/esphome/components/ld600x/const.py new file mode 100644 index 00000000000..252849db8e9 --- /dev/null +++ b/esphome/components/ld600x/const.py @@ -0,0 +1,34 @@ +AREA_COUNT = 4 +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" +CONF_CLEAR_INTERFERENCE = "clear_interference" +CONF_CLUSTER_ID = "cluster_id" +CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" +CONF_GET_DELAY = "get_delay" +CONF_GET_INSTALLATION = "get_installation" +CONF_GET_LOW_POWER_MODE = "get_low_power_mode" +CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" +CONF_GET_SENSITIVITY = "get_sensitivity" +CONF_GET_TRIGGER_SPEED = "get_trigger_speed" +CONF_GET_Z_RANGE = "get_z_range" +CONF_HOLD_DELAY = "hold_delay" +CONF_INSTALLATION_MODE = "installation_mode" +CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" +CONF_OTA_VERSION = "ota_version" +CONF_POINT_CLOUD = "point_cloud" +CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" +CONF_RESET_UNATTENDED = "reset_unattended" +CONF_TARGET_DISPLAY = "target_display" +CONF_TRIGGER_SPEED = "trigger_speed" +CONF_WORK_MODE = "work_mode" +CONF_Z = "z" +CONF_Z_MAX = "z_max" +CONF_Z_MIN = "z_min" +KEY_X_MAX = "x_max" +KEY_X_MIN = "x_min" +KEY_Y_MAX = "y_max" +KEY_Y_MIN = "y_min" +MAX_TARGETS = 3 diff --git a/esphome/components/ld600x/entities.py b/esphome/components/ld600x/entities.py new file mode 100644 index 00000000000..c3c1ebb190a --- /dev/null +++ b/esphome/components/ld600x/entities.py @@ -0,0 +1,640 @@ +from collections.abc import Callable +from typing import Any + +from esphome.automation import parent_ref +import esphome.codegen as cg +from esphome.components import ( + binary_sensor, + button, + number, + select, + sensor, + switch, + text_sensor, +) +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_ID, + CONF_PLATFORM, + CONF_SENSITIVITY, + CONF_TARGET, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_SWITCH, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_METER, + UNIT_MILLISECOND, + UNIT_SECOND, +) +from esphome.core import ID +from esphome.core.entity_helpers import SubEntities +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 .const import ( + AREA_COUNT, + CONF_APPLY_AREA, + CONF_AREA_CONFIG, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_CLUSTER_ID, + CONF_DOPPLER_INDEX, + CONF_GET_AREAS, + CONF_GET_DELAY, + CONF_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME, + CONF_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE, + CONF_HOLD_DELAY, + CONF_INSTALLATION_MODE, + CONF_LOW_POWER_SLEEP_TIME, + CONF_OTA_VERSION, + CONF_POINT_CLOUD, + CONF_POINT_COUNT, + CONF_RESET_DETECTION_AREA, + CONF_RESET_UNATTENDED, + CONF_TARGET_DISPLAY, + CONF_TRIGGER_SPEED, + CONF_WORK_MODE, + CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, + MAX_TARGETS, +) + +_AREA_KINDS = { + "interference": AreaKind.AREA_KIND_INTERFERENCE, + "detection": AreaKind.AREA_KIND_DETECTION, + "dwell": AreaKind.AREA_KIND_DWELL, +} + +_AREA_AXES = ( + (KEY_X_MIN, AreaAxis.AREA_AXIS_X_MIN), + (KEY_X_MAX, AreaAxis.AREA_AXIS_X_MAX), + (KEY_Y_MIN, AreaAxis.AREA_AXIS_Y_MIN), + (KEY_Y_MAX, AreaAxis.AREA_AXIS_Y_MAX), + (CONF_Z_MIN, AreaAxis.AREA_AXIS_Z_MIN), + (CONF_Z_MAX, AreaAxis.AREA_AXIS_Z_MAX), +) + +_METER_SENSOR_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, +) + +_TARGET_AXIS_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, +) + +_METER_NUMBER_SCHEMA = number.number_schema( + number.ApplyNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, +) + + +TARGET_SCHEMA = cv.Schema( + { + **{cv.Optional(key): _TARGET_AXIS_SCHEMA for key in (CONF_X, CONF_Y, CONF_Z)}, + cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( + accuracy_decimals=0, + ), + } +) + + +AREA_SCHEMA = cv.Schema( + {cv.Optional(key): _METER_SENSOR_SCHEMA for key, _ in _AREA_AXES} +) + +_TARGET_KEYS = tuple(f"target_{i + 1}" for i in range(MAX_TARGETS)) +_DETECTION_AREA_KEYS = tuple(f"detection_area_{i}" for i in range(AREA_COUNT)) + + +def _hub_has(domain: str, key: str, hub_id_key: str, hub_id: ID) -> bool: + """Return whether a block of domain on the hub hub_id sets key.""" + return any( + entry.get(hub_id_key) == hub_id and entry.get(key) is not None + for entry in fv.full_config.get().get(domain, []) + ) + + +def sensor_schema( + hub_class: MockObjClass, hub_id_key: str, *, area_kinds: tuple[str, ...] +) -> cv.Schema: + return ( + cv.Schema( + { + cv.GenerateID(hub_id_key): cv.use_id(hub_class), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(key): TARGET_SCHEMA for key in _TARGET_KEYS}) + .extend( + { + cv.Optional(f"{kind}_area_{i}"): AREA_SCHEMA + for kind in area_kinds + for i in range(AREA_COUNT) + } + ) + ) + + +async def sensor_to_code( + config: ConfigType, hub_id_key: str, *, area_kinds: tuple[str, ...] +) -> None: + hub = await cg.get_variable(config[hub_id_key]) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_POINT_COUNT, hub.set_point_count_sensor) + + target_setters = ( + (CONF_X, hub.set_target_x_sensor), + (CONF_Y, hub.set_target_y_sensor), + (CONF_Z, hub.set_target_z_sensor), + (CONF_DOPPLER_INDEX, hub.set_target_dop_idx_sensor), + (CONF_CLUSTER_ID, hub.set_target_cluster_id_sensor), + ) + for i, target_key in enumerate(_TARGET_KEYS): + if target_config := config.get(target_key): + for key, setter in target_setters: + if conf := target_config.get(key): + cg.add(setter(i, await sensor.new_sensor(conf))) + + for kind in area_kinds: + area_kind = _AREA_KINDS[kind] + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + 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)) + + +_OCCUPANCY_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY +) + + +def binary_sensor_schema(hub_class: MockObjClass, hub_id_key: str) -> cv.Schema: + return cv.Schema( + { + cv.GenerateID(hub_id_key): cv.use_id(hub_class), + cv.Optional(CONF_TARGET): _OCCUPANCY_SCHEMA, + **{ + cv.Optional(key): _OCCUPANCY_SCHEMA + for key in (*_TARGET_KEYS, *_DETECTION_AREA_KEYS) + }, + } + ) + + +async def binary_sensor_to_code(config: ConfigType, hub_id_key: str) -> None: + hub = await cg.get_variable(config[hub_id_key]) + + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_TARGET, hub.set_presence_binary_sensor) + + for keys, setter in ( + (_TARGET_KEYS, hub.set_target_presence_binary_sensor), + (_DETECTION_AREA_KEYS, hub.set_area_presence_binary_sensor), + ): + for i, key in enumerate(keys): + if conf := config.get(key): + cg.add(setter(i, await binary_sensor.new_binary_sensor(conf))) + + +def text_sensor_schema(hub_class: MockObjClass, hub_id_key: str) -> cv.Schema: + return cv.Schema( + { + cv.GenerateID(hub_id_key): cv.use_id(hub_class), + cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ) + + +async def text_sensor_to_code(config: ConfigType, hub_id_key: str) -> None: + hub = await cg.get_variable(config[hub_id_key]) + sensors = text_sensor.sub_text_sensors(config) + await sensors(CONF_WORK_MODE, hub.set_work_mode_text_sensor) + await sensors(CONF_OTA_VERSION, hub.set_ota_version_text_sensor) + + +def number_schema( + hub_class: MockObjClass, hub_id_key: str, *, extra: dict[Any, Any] | None = None +) -> cv.Schema: + return ( + cv.Schema( + { + cv.GenerateID(hub_id_key): cv.use_id(hub_class), + cv.Optional(CONF_HOLD_DELAY): number.number_schema( + number.ApplyNumber, + unit_of_measurement=UNIT_SECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): _METER_NUMBER_SCHEMA, + cv.Optional(CONF_Z_MAX): _METER_NUMBER_SCHEMA, + cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( + number.ApplyNumber, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + {cv.Optional(key): _METER_NUMBER_SCHEMA for key, _ in _AREA_AXES} + ), + } + ) + ).extend(extra or {}) + + +def number_final_validate( + hub_id_key: str, component_name: str +) -> Callable[[ConfigType], None]: + unique = unique_settings_final_validate(number.DOMAIN, hub_id_key, component_name) + + def final_validate(config: ConfigType) -> None: + unique(config) + if config.get(CONF_AREA_CONFIG) is None: + return + + hub_id: ID = config[hub_id_key] + if not _hub_has(button.DOMAIN, CONF_APPLY_AREA, hub_id_key, hub_id): + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same {component_name} instance", + path=[CONF_AREA_CONFIG], + ) + if not _hub_has(select.DOMAIN, CONF_AREA_ID, hub_id_key, hub_id): + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same {component_name} instance", + path=[CONF_AREA_CONFIG], + ) + + return final_validate + + +# The apply call picks the setting by entity pointer, so every number shares one generated class. +NUMBER_CONTROL: dict[str, str] = { + "apply": "set_number_value(entity, value)", + "publish": "before", +} + + +async def number_to_code(config: ConfigType, hub_id_key: str) -> SubEntities: + """Create the shared numbers and return the bound helper for a model's own numbers.""" + hub = await cg.get_variable(config[hub_id_key]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_HOLD_DELAY, + hub.set_hold_delay_number, + min_value=0, + max_value=65535, + step=1, + **NUMBER_CONTROL, + ) + meters: dict[str, Any] = { + "min_value": -10, + "max_value": 10, + "step": 0.1, + **NUMBER_CONTROL, + } + await numbers(CONF_Z_MIN, hub.set_z_min_number, **meters) + await numbers(CONF_Z_MAX, hub.set_z_max_number, **meters) + # 0x0205 is a uint32 of ms with no documented maximum; cap it at a minute + await numbers( + CONF_LOW_POWER_SLEEP_TIME, + hub.set_low_power_sleep_number, + min_value=0, + max_value=60000, + step=100, + **NUMBER_CONTROL, + ) + + if area_config := config.get(CONF_AREA_CONFIG): + area = number.sub_numbers(area_config, parent=hub) + await area(KEY_X_MIN, hub.set_area_x_min_number, **meters) + await area(KEY_X_MAX, hub.set_area_x_max_number, **meters) + await area(KEY_Y_MIN, hub.set_area_y_min_number, **meters) + await area(KEY_Y_MAX, hub.set_area_y_max_number, **meters) + await area(CONF_Z_MIN, hub.set_area_z_min_number, **meters) + await area(CONF_Z_MAX, hub.set_area_z_max_number, **meters) + return numbers + + +def select_schema( + hub_class: MockObjClass, hub_id_key: str, *, extra: dict[Any, Any] | None = None +) -> cv.Schema: + return ( + cv.Schema( + { + cv.GenerateID(hub_id_key): cv.use_id(hub_class), + cv.Optional(CONF_SENSITIVITY): select.select_schema( + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AREA_ID): select.select_schema( + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + } + ) + ).extend(extra or {}) + + +SELECT_CONTROL: dict[str, str] = { + "apply": "set_select_value(entity, index)", + "publish": "before", +} + + +async def select_to_code( + config: ConfigType, hub_id_key: str, *, area_kinds: tuple[str, ...] +) -> SubEntities: + """Create the shared selects and return the bound helper for a model's own selects.""" + hub = await cg.get_variable(config[hub_id_key]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_SENSITIVITY, + hub.set_sensitivity_select, + options=["low", "medium", "high"], + **SELECT_CONTROL, + ) + await selects( + CONF_TRIGGER_SPEED, + hub.set_trigger_speed_select, + options=["slow", "medium", "fast"], + **SELECT_CONTROL, + ) + await selects( + CONF_INSTALLATION_MODE, + hub.set_installation_select, + options=["top", "side"], + **SELECT_CONTROL, + ) + await selects( + CONF_AREA_ID, + hub.set_area_id_select, + options=area_id_options(area_kinds), + **SELECT_CONTROL, + ) + 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( + switch.ApplySwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + ), +} + + +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 + }, + } + ).extend(extra or {}) + + +SWITCH_CONTROL: dict[str, str] = { + "apply": "set_switch_state(entity, state)", + "publish": "after", +} + + +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( + 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_GET_AREAS: DIAGNOSTIC_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, + CONF_GET_Z_RANGE: DIAGNOSTIC_BUTTON_SCHEMA, + 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, +} + + +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 + }, + } + ).extend(extra or {}) + + +def button_final_validate( + hub_id_key: str, component_name: str +) -> Callable[[ConfigType], None]: + def final_validate(config: ConfigType) -> None: + hub_id: ID = config[hub_id_key] + + if config.get(CONF_APPLY_AREA) and not _hub_has( + select.DOMAIN, CONF_AREA_ID, hub_id_key, hub_id + ): + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same {component_name} instance", + path=[CONF_APPLY_AREA], + ) + + return final_validate + + +BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.BUTTON_APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.BUTTON_AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.BUTTON_GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.BUTTON_CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.BUTTON_RESET_DETECTION_AREA, + CONF_GET_DELAY: ButtonType.BUTTON_GET_DELAY, + CONF_GET_SENSITIVITY: ButtonType.BUTTON_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED: ButtonType.BUTTON_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE: ButtonType.BUTTON_GET_Z_RANGE, + CONF_GET_INSTALLATION: ButtonType.BUTTON_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE: ButtonType.BUTTON_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.BUTTON_GET_LOW_POWER_SLEEP_TIME, + CONF_RESET_UNATTENDED: ButtonType.BUTTON_RESET_UNATTENDED, +} + + +async def button_to_code( + config: ConfigType, + hub_id_key: str, + *, + extra: dict[str, MockObj] | None = None, +) -> None: + extra = extra or {} + confs: list[tuple[MockObj, ConfigType]] = [ + (button_type, conf) + for key, button_type in {**BUTTON_MAP, **extra}.items() + if (conf := config.get(key)) is not None + ] + if not confs: + return + if any(key in config for key in extra): + # Two enum types in one ?: chain draw a warning, so a model's buttons put everything in uint8_t. + confs = [ + (cg.RawExpression(f"static_cast({button_type})"), conf) + for button_type, conf in confs + ] + hub = await cg.get_variable(config[hub_id_key]) + params = button.APPLY_ENTITY.params + # One function for all buttons, picking the type by pointer: one class instead of one per button. + # Named after the first button so two button blocks on one hub do not clash. + fn = f"esphome__{confs[0][1][CONF_ID].id}__button_fn" + cg.add_global( + cg.RawStatement(f"static void {fn}({ParameterListExpression(*params)});") + ) + template = cg.TemplateArguments(cg.RawExpression(fn)) + created: list[tuple[MockObj, MockObj]] = [ + (button_type, await button.new_button(conf, template)) + for button_type, conf in confs + ] + *head, (last_type, _) = created + chain = "".join(f"entity == {parent_ref(var)} ? {t} : " for t, var in head) + cg.static_function( + fn, + cg.void, + list(params), + [ + f"const auto type = {chain}{last_type};", + f"{parent_ref(hub)}->press_button(type);", + ], + ) + + +def unique_settings_final_validate( + domain: str, hub_id_key: str, component_name: str +) -> Callable[[ConfigType], None]: + """Reject a setting set in two platform blocks of one hub, which keeps one entity per setting.""" + + def final_validate(config: ConfigType) -> None: + hub_id: ID = config[hub_id_key] + entries: list[ConfigType] = [ + entry + for entry in fv.full_config.get().get(domain, []) + if entry.get(CONF_PLATFORM) == component_name + and entry.get(hub_id_key) == hub_id + ] + for key, value in config.items(): + if key in (CONF_PLATFORM, hub_id_key): + continue + # area_config groups six settings, so each one is checked on its own + for path in ( + [[key, sub] for sub in value] if key == CONF_AREA_CONFIG else [[key]] + ): + if sum(_has_path(entry, path) for entry in entries) > 1: + raise cv.Invalid( + f"{'.'.join(path)} is already set for this {component_name} in another {domain} block", + path=path, + ) + + return final_validate + + +def _has_path(entry: ConfigType, path: list[str]) -> bool: + for key in path: + if key not in entry: + return False + entry = entry[key] + return True + + +def area_id_options(area_kinds: tuple[str, ...]) -> list[str]: + return [f"{kind}_area_{i}" for kind in area_kinds for i in range(AREA_COUNT)] diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp new file mode 100644 index 00000000000..92ab90dd657 --- /dev/null +++ b/esphome/components/ld600x/ld600x.cpp @@ -0,0 +1,1460 @@ +#include "ld600x.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include +#include +#include +#include +#include +#include + +namespace esphome::ld600x { + +// Every log line in this file carries the derived model's tag. The ESP_LOG* and LOG_* macros take the +// identifier TAG, so it resolves to the per-instance tag inside the member functions below. +#define TAG (this->log_tag_) + +static constexpr uint32_t SETUP_DELAY_MS = 100; + +// Command/message types +static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; +static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; + +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; +static constexpr uint16_t TYPE_REPORT_MODE = 0x0A12; // low power on the LD6002B, work mode on the LD6004 +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +#if LD600X_AREA_KINDS > 2 +static constexpr uint16_t TYPE_REPORT_DWELL_AREAS = 0x0A16; +#endif +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; + +// Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; +static constexpr uint32_t CMD_GET_DELAY = 0x05; +static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; +static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; +static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; +static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; +static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; +static constexpr uint32_t CMD_GET_TRIGGER = 0x11; +static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_INSTALL_TOP = 0x13; +static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; +// Both models answer 0x16 to 0x18 with report 0x0A12; the opcodes stay here for the verbose logs. +static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; +static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; +// Asks for report 0x0A12, which each model reads its own way: low power on the LD6002B, the work mode +// on the LD6004. Only the shared get_low_power_mode button sends it from here. +static constexpr uint32_t CMD_GET_MODE = 0x18; +static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; +static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; + +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display + +static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE +// Indexed by control command code - 1 (codes run 0x01 to 0x1A); the last entry is the fallback. +PROGMEM_STRING_TABLE(ControlCommandNames, "auto_interference", "get_areas", "clear_interference", + "reset_detection_area", "get_delay", "point_cloud_on", "point_cloud_off", "target_display_on", + "target_display_off", "sensitivity_low", "sensitivity_medium", "sensitivity_high", + "get_sensitivity", "trigger_slow", "trigger_medium", "trigger_fast", "get_trigger", "get_z_range", + "install_top", "install_side", "get_installation", "low_power_on", "low_power_off", "get_mode", + "get_low_power_sleep", "reset_unattended", "unknown"); +static_assert(CMD_AUTO_INTERFERENCE == 1 && CMD_RESET_UNATTENDED == ControlCommandNames::LAST_INDEX, + "ControlCommandNames is indexed by command - 1"); + +static const LogString *control_command_name(uint32_t command) { + // Range checked in 32 bits: get_log_str takes a uint8_t index; command 0 wraps and falls back too + const uint32_t idx = command - 1; + return ControlCommandNames::get_log_str(idx < ControlCommandNames::COUNT ? idx : ControlCommandNames::LAST_INDEX, + ControlCommandNames::LAST_INDEX); +} + +static const LogString *frame_type_name(uint16_t type) { + switch (type) { + case TYPE_CONTROL: + return LOG_STR("control"); + case TYPE_SET_AREA: + return LOG_STR("set_area"); + case TYPE_SET_HOLD_DELAY: + return LOG_STR("set_hold_delay"); + case TYPE_SET_Z_RANGE: + return LOG_STR("set_z_range"); + case TYPE_SET_LOW_POWER_SLEEP: + return LOG_STR("set_low_power_sleep"); + case TYPE_REPORT_TARGET: + return LOG_STR("report_target"); + case TYPE_REPORT_POINT_CLOUD: + return LOG_STR("report_point_cloud"); + case TYPE_REPORT_AREA_PRESENCE: + return LOG_STR("report_area_presence"); + case TYPE_REPORT_INTERFERENCE_AREAS: + return LOG_STR("report_interference_areas"); + case TYPE_REPORT_DETECTION_AREAS: + return LOG_STR("report_detection_areas"); + case TYPE_REPORT_DELAY: + return LOG_STR("report_delay"); + case TYPE_REPORT_SENSITIVITY: + return LOG_STR("report_sensitivity"); + case TYPE_REPORT_TRIGGER: + return LOG_STR("report_trigger"); + case TYPE_REPORT_Z_RANGE: + return LOG_STR("report_z_range"); + case TYPE_REPORT_INSTALLATION: + return LOG_STR("report_installation"); + case TYPE_REPORT_MODE: + return LOG_STR("report_mode"); + case TYPE_REPORT_LOW_POWER_SLEEP: + return LOG_STR("report_low_power_sleep"); + case TYPE_QUERY_VERSION: + return LOG_STR("query_version"); + default: + return LOG_STR("unknown"); + } +} + +static bool is_expected_control_report(uint32_t command, uint16_t type) { + switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; + case CMD_GET_DELAY: + return type == TYPE_REPORT_DELAY; + case CMD_GET_SENSITIVITY: + return type == TYPE_REPORT_SENSITIVITY; + case CMD_GET_TRIGGER: + return type == TYPE_REPORT_TRIGGER; + case CMD_GET_Z_RANGE: + return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_INSTALLATION: + return type == TYPE_REPORT_INSTALLATION; + case CMD_LOW_POWER_ON: + case CMD_LOW_POWER_OFF: + case CMD_GET_MODE: + return type == TYPE_REPORT_MODE; + case CMD_GET_LOW_POWER_SLEEP: + return type == TYPE_REPORT_LOW_POWER_SLEEP; + default: + return false; + } +} +#endif + +void LD600XComponent::setup() { + // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. + bool point_cloud_configured = false; +#ifdef USE_SENSOR + point_cloud_configured = point_cloud_configured || this->point_count_sensor_.has_sensor(); +#endif +#ifdef USE_SWITCH + point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; +#endif + const uint16_t capacity = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; + // One allocation for the component lifetime; the parser reuses it for every payload. + RAMAllocator allocator; + uint8_t *buffer = allocator.allocate(capacity); + if (buffer == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + this->parser_.init(buffer, capacity); + + this->set_timeout(SETUP_DELAY_MS, [this]() { + bool want_target_stream = false; +#ifdef USE_SENSOR + want_target_stream = want_target_stream || this->target_count_sensor_.has_sensor(); + if (!want_target_stream) { + for (const auto &target : this->targets_) { + if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr || + target.cluster_id.has_sensor()) { + want_target_stream = true; + break; + } + } + } +#endif +#ifdef USE_BINARY_SENSOR + want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; + if (!want_target_stream) { + for (auto *sensor : this->target_presence_) { + if (sensor != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif + // This only feeds the automatic branch below: with a target_display switch + // configured that switch still decides. + want_target_stream = want_target_stream || this->model_wants_target_stream(); + bool target_display_controlled = false; +#ifdef USE_SWITCH + if (this->target_display_switch_ != nullptr) { + target_display_controlled = true; + // Nothing reports this switch back, so its restored state is the only state there is. + this->target_display_switch_->restore(true); + } +#endif + if (!target_display_controlled) { + // No switch: the stream follows its consumers. With none, nothing is sent + // and the module's own default stands -- but the reports are gated out + // regardless, because there is nothing configured for them to feed. + this->target_display_enabled_ = want_target_stream; + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } + } + + bool point_cloud_controlled = false; +#ifdef USE_SWITCH + if (this->point_cloud_switch_ != nullptr) { + point_cloud_controlled = true; + this->point_cloud_switch_->restore(false); + } +#endif + if (!point_cloud_controlled) { + // No switch: the stream follows the sensor that reads it, which is also what + // the frame buffer above was sized for. + bool want_point_cloud = false; +#ifdef USE_SENSOR + want_point_cloud = this->point_count_sensor_.has_sensor(); +#endif + this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); + this->point_cloud_enabled_ = want_point_cloud; + } + +#ifdef USE_SELECT + if (this->sensitivity_select_ != nullptr) { + this->send_control_command_(CMD_GET_SENSITIVITY); + } + if (this->trigger_speed_select_ != nullptr) { + this->send_control_command_(CMD_GET_TRIGGER); + } + if (this->installation_select_ != nullptr) { + this->send_control_command_(CMD_GET_INSTALLATION); + } +#endif +#ifdef USE_NUMBER + if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { + this->send_control_command_(CMD_GET_Z_RANGE); + } + if (this->low_power_sleep_number_ != nullptr) { + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + } + if (this->hold_delay_number_ != nullptr) { + this->send_control_command_(CMD_GET_DELAY); + } +#endif + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->areas_) { + for (auto *sensor : area) + want_area_report = want_area_report || sensor != nullptr; + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); + this->init_version_pref_(); + +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ != nullptr) { + this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); + } +#endif + this->setup_model(); + }); +} + +void LD600XComponent::dump_config() { + // "HLK-" plus the upper-cased tag. The tag sits in flash on ESP8266, so it is read a byte at a time. + char model[16] = "HLK-"; + size_t pos = 4; + for (const auto *p = reinterpret_cast(this->log_tag_); pos < sizeof(model) - 1; p++) { + const uint8_t c = progmem_read_byte(p); + if (c == 0) + break; + model[pos++] = static_cast(std::toupper(c)); + } + model[pos] = '\0'; + ESP_LOGCONFIG(TAG, + "%s:\n" + " Max data length: %u", + model, static_cast(this->parser_.capacity())); +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP(" ", "Target Count", this->target_count_sensor_); + LOG_SENSOR_WITH_DEDUP(" ", "Point Count", this->point_count_sensor_); + for (auto &target : this->targets_) { + LOG_SENSOR(" ", "Target X", target.x); + LOG_SENSOR(" ", "Target Y", target.y); + LOG_SENSOR(" ", "Target Z", target.z); + LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); + LOG_SENSOR_WITH_DEDUP(" ", "Target Cluster ID", target.cluster_id); + } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + const AreaSensors &area = this->areas_[area_index(AREA_KIND_INTERFERENCE, i)]; + LOG_SENSOR(" ", "Interference Area X Min", area[AREA_AXIS_X_MIN]); + LOG_SENSOR(" ", "Interference Area X Max", area[AREA_AXIS_X_MAX]); + LOG_SENSOR(" ", "Interference Area Y Min", area[AREA_AXIS_Y_MIN]); + LOG_SENSOR(" ", "Interference Area Y Max", area[AREA_AXIS_Y_MAX]); + LOG_SENSOR(" ", "Interference Area Z Min", area[AREA_AXIS_Z_MIN]); + LOG_SENSOR(" ", "Interference Area Z Max", area[AREA_AXIS_Z_MAX]); + } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + const AreaSensors &area = this->areas_[area_index(AREA_KIND_DETECTION, i)]; + LOG_SENSOR(" ", "Detection Area X Min", area[AREA_AXIS_X_MIN]); + LOG_SENSOR(" ", "Detection Area X Max", area[AREA_AXIS_X_MAX]); + LOG_SENSOR(" ", "Detection Area Y Min", area[AREA_AXIS_Y_MIN]); + LOG_SENSOR(" ", "Detection Area Y Max", area[AREA_AXIS_Y_MAX]); + LOG_SENSOR(" ", "Detection Area Z Min", area[AREA_AXIS_Z_MIN]); + LOG_SENSOR(" ", "Detection Area Z Max", area[AREA_AXIS_Z_MAX]); + } +#if LD600X_AREA_KINDS > 2 + for (uint8_t i = 0; i < AREA_COUNT; i++) { + const AreaSensors &area = this->areas_[area_index(AREA_KIND_DWELL, i)]; + LOG_SENSOR(" ", "Dwell Area X Min", area[AREA_AXIS_X_MIN]); + LOG_SENSOR(" ", "Dwell Area X Max", area[AREA_AXIS_X_MAX]); + LOG_SENSOR(" ", "Dwell Area Y Min", area[AREA_AXIS_Y_MIN]); + LOG_SENSOR(" ", "Dwell Area Y Max", area[AREA_AXIS_Y_MAX]); + LOG_SENSOR(" ", "Dwell Area Z Min", area[AREA_AXIS_Z_MIN]); + LOG_SENSOR(" ", "Dwell Area Z Max", area[AREA_AXIS_Z_MAX]); + } +#endif +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); + for (uint8_t i = 0; i < LD600X_MAX_TARGETS; i++) { + LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); + } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); + LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); +#endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); + LOG_NUMBER(" ", "Z Min", this->z_min_number_); + LOG_NUMBER(" ", "Z Max", this->z_max_number_); + LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); + LOG_SWITCH(" ", "Target Display", this->target_display_switch_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); + LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); + LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); +#endif + this->dump_model_config(); +} + +void LD600XComponent::loop() { + this->read_available([this](uint8_t byte) { + if (this->parser_.feed(byte)) { + this->handle_frame_(this->parser_.type(), this->parser_.data(), this->parser_.length()); + } + }); + this->process_command_queue_(); +} + +void LD600XComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) { + const uint32_t now = App.get_loop_component_start_time(); + this->last_traffic_ms_ = now; + if (this->stale_ack_count_ > 0 && now - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) { + this->stale_ack_count_ = 0; + } + // ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command. + if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) { + this->stale_ack_count_--; + ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type, + this->parser_.id()); + return; + } + if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { + ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->parser_.id()); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; + // This settles one expected reply; the rest stay owed and become the debt for the next command. + this->send_generation_++; + this->stale_ack_type_ = type; + this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast(this->acks_expected_ - 1) : 0; + this->stale_ack_ms_ = now; + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT_ID, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } + return; + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + const uint32_t active_control_command = + (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? encode_value_le(this->active_command_.data.data()) + : 0; + if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { + ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", LOG_STR_ARG(frame_type_name(type)), + type, LOG_STR_ARG(control_command_name(active_control_command)), active_control_command); + } +#endif + + if (this->handle_model_report(type, data, len)) + return; + + switch (type) { + case TYPE_REPORT_TARGET: + this->handle_target_report_(data, len); + break; + case TYPE_REPORT_POINT_CLOUD: + this->handle_point_cloud_(data, len); + break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(AREA_KIND_INTERFERENCE, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(AREA_KIND_DETECTION, data, len); + break; +#if LD600X_AREA_KINDS > 2 + case TYPE_REPORT_DWELL_AREAS: + this->handle_area_report_(AREA_KIND_DWELL, data, len); + break; +#endif + case TYPE_REPORT_DELAY: + this->handle_delay_report_(data, len); + break; + case TYPE_REPORT_SENSITIVITY: + this->handle_sensitivity_report_(data, len); + break; + case TYPE_REPORT_TRIGGER: + this->handle_trigger_speed_report_(data, len); + break; + case TYPE_REPORT_Z_RANGE: + this->handle_z_range_report_(data, len); + break; + case TYPE_REPORT_INSTALLATION: + this->handle_installation_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER_SLEEP: + this->handle_low_power_sleep_report_(data, len); + break; + case TYPE_QUERY_VERSION: + this->handle_version_report_(data, len); + break; + default: + break; + } +} + +void LD600XComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + // The module stops streaming when it acts on the command, not when the command + // is queued, so trailing frames after an off must not repopulate what + // set_switch_state just cleared. + if (!this->target_display_enabled_) { + return; + } + if (len < 4) + return; + + uint32_t target_num = encode_value_le(data); + uint16_t available = (len - 4) / TARGET_DATA_LEN; + // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". + const uint32_t reported = std::min(target_num, available); + uint8_t count = static_cast(std::min(reported, LD600X_MAX_TARGETS)); + + // The module re-sorts its array by cluster id, so slots key on the id to track the person. + std::array wire_cluster{}; + std::array wire_placed{}; + std::array slot_seen{}; + std::array slot_wire{}; + for (uint8_t i = 0; i < count; i++) { + uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; + wire_cluster[i] = encode_value_le(data + cluster_offset); + } + for (uint8_t i = 0; i < count; i++) { + for (uint8_t s = 0; s < LD600X_MAX_TARGETS; s++) { + if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { + slot_seen[s] = true; + wire_placed[i] = true; + slot_wire[s] = i; + break; + } + } + } + for (uint8_t s = 0; s < LD600X_MAX_TARGETS; s++) { + if (!slot_seen[s]) { + this->slot_occupied_[s] = false; + } + } + for (uint8_t i = 0; i < count; i++) { + if (wire_placed[i]) { + continue; + } + for (uint8_t s = 0; s < LD600X_MAX_TARGETS; s++) { + if (!this->slot_occupied_[s]) { + this->slot_occupied_[s] = true; + this->slot_cluster_[s] = wire_cluster[i]; + slot_wire[s] = i; + break; + } + } + } + +#ifdef USE_SENSOR + this->target_count_sensor_.publish_state_if_not_dup(reported); +#endif + + this->target_presence_any_ = (reported > 0); +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + PUBLISH_STATE(this->presence_binary_sensor_, presence); +#endif + this->on_presence_update(); + + for (uint8_t i = 0; i < LD600X_MAX_TARGETS; i++) { + bool has_target = this->slot_occupied_[i]; + if (has_target) { +#ifdef USE_SENSOR + uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN); + float x = read_f32_le(data + offset + 0); + float y = read_f32_le(data + offset + 4); + float z = read_f32_le(data + offset + 8); + int32_t dop_idx = encode_value_le(data + offset + 12); + int32_t cluster_id = this->slot_cluster_[i]; + TargetSensors &target = this->targets_[i]; + PUBLISH_STATE(target.x, x); + PUBLISH_STATE(target.y, y); + PUBLISH_STATE(target.z, z); + PUBLISH_STATE(target.dop_idx, static_cast(dop_idx)); + target.cluster_id.publish_state_if_not_dup(cluster_id); +#endif + } else { +#ifdef USE_SENSOR + this->clear_target_slot_(i); +#endif + } +#ifdef USE_BINARY_SENSOR + // publish_state() already skips unchanged states, no manual de-dup needed. + PUBLISH_STATE(this->target_presence_[i], has_target); +#endif +#ifdef USE_SENSOR + this->last_target_presence_[i] = has_target; +#endif + } +} + +void LD600XComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { + // Same window as the target stream: a frame already in flight must not put the + // count back after the switch cleared it. + if (!this->point_cloud_enabled_) { + return; + } + if (len < 4) + return; + +#ifdef USE_SENSOR + this->point_count_sensor_.publish_state_if_not_dup(encode_value_le(data)); +#endif +} + +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD600XComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = encode_value_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + PUBLISH_STATE(this->area_presence_[i], present); +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + PUBLISH_STATE(this->presence_binary_sensor_, presence); +#endif + this->on_presence_update(); +} + +void LD600XComponent::handle_area_report_(uint8_t kind, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + const AreaSensors &area = this->areas_[area_index(kind, i)]; + const float values[AREA_AXIS_COUNT] = {x_min, x_max, y_min, y_max, z_min, z_max}; + for (uint8_t axis = 0; axis < AREA_AXIS_COUNT; axis++) + PUBLISH_STATE(area[axis], values[axis]); +#endif + + AreaConfig &store = this->area_values_[area_index(kind, i)]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (selected_id == area_index(kind, i)) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(kind); +} + +void LD600XComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t delay = encode_value_le(data); + this->publish_number_clamped_(this->hold_delay_number_, delay); +#endif +} + +void LD600XComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->sensitivity_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->sensitivity_select_->publish_state(value); + } +#endif +} + +void LD600XComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->trigger_speed_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->trigger_speed_select_->publish_state(value); + } +#endif +} + +void LD600XComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { + if (len < 8) + return; + float z_min = read_f32_le(data); + float z_max = read_f32_le(data + 4); + this->z_min_ = z_min; + this->z_max_ = z_max; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->z_min_number_, z_min); + this->publish_number_clamped_(this->z_max_number_, z_max); +#endif +} + +void LD600XComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->installation_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 1) { + this->installation_select_->publish_state(value); + } +#endif +} + +void LD600XComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t sleep_ms = encode_value_le(data); + this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); +#endif +} + +#ifdef USE_NUMBER +void LD600XComponent::publish_number_clamped_(number::Number *number, float value) { + if (number == nullptr) + return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } + const float min_value = number->traits.get_min_value(); + const float max_value = number->traits.get_max_value(); + // Outside the declared range the user cannot write the value back, so publish + // what they can reach and say what the module actually sent. + if (value < min_value || value > max_value) { + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", LOG_STR_ARG(number->get_log_name()), value, + min_value, max_value); + value = std::clamp(value, min_value, max_value); + } + number->publish_state(value); +} +#endif + +void LD600XComponent::handle_version_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) + return; + uint8_t project = data[0]; + uint8_t major = data[1]; + uint8_t minor = data[2]; + uint8_t patch = data[3]; + char buf[32]; + if (project == 0) { + std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); + } else { + std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); + } + this->ota_version_text_sensor_->publish_state(buf); + this->save_version_pref_(buf); +#endif +} + +bool LD600XComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + return false; + } + if (this->cmd_queue_.size() >= CMD_QUEUE_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); + return false; + } + + PendingCommand cmd; + cmd.type = type; + cmd.len = len; + if (len > 0 && data != nullptr) { + std::memcpy(cmd.data.data(), data, len); + } + this->cmd_queue_.push(cmd); + + this->process_command_queue_(); + return true; +} + +void LD600XComponent::process_command_queue_() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->command_active_) { + // A sleeping module consumes the opening attempt as its wake-up instead of answering it. + const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS; + if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { + const uint32_t active_control_command = + (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? encode_value_le(this->active_command_.data.data()) + : 0; + if (this->retries_left_ > 0) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + if (active_control_command != 0) { + ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining", + LOG_STR_ARG(control_command_name(active_control_command)), active_control_command, + this->retries_left_); + } else { + // Writes without a control subcommand (hold delay, z-range) had no retry trace at all. + ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type, + this->retries_left_); + } +#endif + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); + this->retries_left_--; + } else { + if (active_control_command != 0) { + ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type, + active_control_command); + } else { + ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); + } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } + // A reply may still be in flight for the attempt we just gave up on, so carry one over as + // debt rather than clearing the ledger, or that late ACK would retire the successor. Only + // one: reaching this point means nothing was answered at all, so the older attempts are + // speculative, and carrying them would swallow the successor's own replies. + const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) + + (this->acks_expected_ > 0 ? 1 : 0); + this->stale_ack_type_ = this->active_command_.type; + this->stale_ack_count_ = static_cast(std::min(owed, 255)); + this->stale_ack_ms_ = now; + this->send_generation_++; + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + } + return; + } + + if (this->cmd_queue_.empty()) + return; + + this->active_command_ = this->cmd_queue_.front(); + this->cmd_queue_.pop(); + + this->send_generation_++; + this->retries_left_ = CMD_MAX_RETRIES; + this->command_active_ = true; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->attempts_sent_ = 0; + this->acks_expected_ = 0; + if (this->stale_ack_type_ != this->active_command_.type) { + this->stale_ack_count_ = 0; + } + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); +} + +void LD600XComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) { + this->send_command_internal_(type, data, len, true); +} + +void LD600XComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + if (track) { + // Release the slot: an unwritten command is never acked and never times out. + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + return; + } + + if (this->defer_frame_write(type, data, len, track)) + return; + this->write_frame_(type, data, len, track); +} + +void LD600XComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + const uint16_t frame_id = next_frame_id(this->next_frame_id_); + // send_command_internal_ rejects anything longer than CMD_MAX_DATA_LEN before it gets here. + uint8_t frame[TF_FRAME_OVERHEAD + CMD_MAX_DATA_LEN]; + size_t frame_len = encode_frame(frame_id, type, data, len, frame); + this->write_array(frame, frame_len); + const uint32_t now = App.get_loop_component_start_time(); + if (track) { + // A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered. + if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) { + this->acks_expected_++; + } + this->last_send_ms_ = now; + this->command_sent_ = true; + this->attempts_sent_++; + } + this->last_traffic_ms_ = now; +} + +bool LD600XComponent::send_control_command_(uint32_t command) { + uint8_t data[4]; + write_u32_le(data, command); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); +} + +void LD600XComponent::send_z_range_() { + // One frame carries both bounds, so half a range cannot be written. + if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { + ESP_LOGW(TAG, "Z range not written, other bound unknown"); + return; + } + // Both bounds are known and crossed; the frame has no way to say that. + if (this->z_min_ > this->z_max_) { + ESP_LOGW(TAG, "Z range not written, min above max"); + return; + } + uint8_t data[8]; + write_f32_le(data, this->z_min_); + write_f32_le(data + 4, this->z_max_); + this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); +} + +void LD600XComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + AreaConfig desired = this->area_values_[this->area_id_]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + +#ifdef USE_NUMBER +// Each setter picks its setting by entity pointer, so the entities share one generated class. +void LD600XComponent::set_number_value(number::Number *entity, float value) { + if (entity == this->hold_delay_number_ || entity == this->low_power_sleep_number_) { + uint8_t data[4]; + write_u32_le(data, static_cast(value)); + this->queue_command_(entity == this->hold_delay_number_ ? TYPE_SET_HOLD_DELAY : TYPE_SET_LOW_POWER_SLEEP, data, + sizeof(data)); + } else if (entity == this->z_min_number_) { + this->z_min_ = value; + this->send_z_range_(); + } else if (entity == this->z_max_number_) { + this->z_max_ = value; + this->send_z_range_(); + } else if (entity == this->area_x_min_number_) { + this->area_x_min_ = value; + this->area_edits_.x_min = value; + } else if (entity == this->area_x_max_number_) { + this->area_x_max_ = value; + this->area_edits_.x_max = value; + } else if (entity == this->area_y_min_number_) { + this->area_y_min_ = value; + this->area_edits_.y_min = value; + } else if (entity == this->area_y_max_number_) { + this->area_y_max_ = value; + this->area_edits_.y_max = value; + } else if (entity == this->area_z_min_number_) { + this->area_z_min_ = value; + this->area_edits_.z_min = value; + } else { // Only registered entities call this, so the last one needs no compare + this->area_z_max_ = value; + this->area_edits_.z_max = value; + } +} +#endif + +#ifdef USE_SELECT +void LD600XComponent::set_select_value(select::Select *entity, size_t index) { + if (entity == this->sensitivity_select_) { + if (index == 0) { + this->send_control_command_(CMD_SENSITIVITY_LOW); + } else if (index == 1) { + this->send_control_command_(CMD_SENSITIVITY_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_SENSITIVITY_HIGH); + } + } else if (entity == this->trigger_speed_select_) { + if (index == 0) { + this->send_control_command_(CMD_TRIGGER_SLOW); + } else if (index == 1) { + this->send_control_command_(CMD_TRIGGER_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_TRIGGER_FAST); + } + } else if (entity == this->installation_select_) { + if (index == 0) { + this->send_control_command_(CMD_INSTALL_TOP); + } else if (index == 1) { + this->send_control_command_(CMD_INSTALL_SIDE); + } + } else { // Only registered entities call this, so the last one needs no compare + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + } +} +#endif + +void LD600XComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD600XComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD600XComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const AreaConfig &area = this->area_values_[area_id]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD600XComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_u32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + AreaConfig &store = this->area_values_[area_id]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD600XComponent::try_apply_pending_area_(uint8_t reported_kind) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const uint8_t kind = this->pending_area_id_ / AREA_COUNT; + AreaConfig desired = this->area_values_[this->pending_area_id_]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_kind == kind) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD600XComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD600XComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + +void LD600XComponent::init_version_pref_() { +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) { + return; + } + this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); + this->version_pref_initialized_ = true; + + VersionPref pref{}; + if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { + pref.value[sizeof(pref.value) - 1] = '\0'; + this->ota_version_text_sensor_->publish_state(pref.value); + } +#endif +} + +void LD600XComponent::save_version_pref_(const char *value) { +#ifdef USE_TEXT_SENSOR + if (!this->version_pref_initialized_) { + return; + } + VersionPref pref{}; + std::strncpy(pref.value, value, sizeof(pref.value) - 1); + pref.value[sizeof(pref.value) - 1] = '\0'; + this->version_pref_.save(&pref); +#endif +} + +#ifdef USE_SENSOR +void LD600XComponent::clear_target_slot_(uint8_t index) { + if (!this->last_target_presence_[index]) { + return; + } + TargetSensors &target = this->targets_[index]; + PUBLISH_STATE(target.x, NAN); + PUBLISH_STATE(target.y, NAN); + PUBLISH_STATE(target.z, NAN); + PUBLISH_STATE(target.dop_idx, NAN); + // The slot is free: the next person's id is new even when it repeats this one. + target.cluster_id.publish_state_unknown(); +} +#endif + +void LD600XComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD600XComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + PUBLISH_STATE(this->area_presence_[i], false); + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + PUBLISH_STATE(this->presence_binary_sensor_, presence); +#endif +} + +void LD600XComponent::clear_target_state_() { + // Nothing corrects any of this until the stream comes back. The slot table goes + // with it: slots key on cluster ids, which only track a person while reports are + // arriving, and the room can empty and refill across the gap -- so the next + // report starts from an empty table and fills slots in wire order, rather than + // handing one back to whoever last held that id. + for (uint8_t i = 0; i < LD600X_MAX_TARGETS; i++) { +#ifdef USE_SENSOR + this->clear_target_slot_(i); + this->last_target_presence_[i] = false; +#endif + if (this->slot_occupied_[i]) { + this->slot_occupied_[i] = false; +#ifdef USE_BINARY_SENSOR + PUBLISH_STATE(this->target_presence_[i], false); +#endif + } + } +#ifdef USE_SENSOR + this->target_count_sensor_.publish_state_unknown(); +#endif + if (this->target_presence_any_) { + this->target_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + PUBLISH_STATE(this->presence_binary_sensor_, presence); +#endif + this->on_presence_update(); + } +} + +#ifdef USE_SWITCH +void LD600XComponent::set_switch_state(switch_::Switch *entity, bool state) { + if (entity == this->point_cloud_switch_) { + 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 + // new again when the stream comes back. + if (!state) + this->point_count_sensor_.publish_state_unknown(); +#endif + } else { // Only registered entities call this, so the last one needs no compare + 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_(); + } + } +} +#endif + +#ifdef USE_BUTTON +void LD600XComponent::press_button(uint8_t kind) { + switch (kind) { + case BUTTON_APPLY_AREA: + this->apply_area_config_(); + break; + case BUTTON_AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case BUTTON_GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case BUTTON_CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case BUTTON_RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; + case BUTTON_GET_DELAY: + this->send_control_command_(CMD_GET_DELAY); + break; + case BUTTON_GET_SENSITIVITY: + this->send_control_command_(CMD_GET_SENSITIVITY); + break; + case BUTTON_GET_TRIGGER_SPEED: + this->send_control_command_(CMD_GET_TRIGGER); + break; + case BUTTON_GET_Z_RANGE: + this->send_control_command_(CMD_GET_Z_RANGE); + break; + case BUTTON_GET_INSTALLATION: + this->send_control_command_(CMD_GET_INSTALLATION); + break; + case BUTTON_GET_LOW_POWER_MODE: + this->send_control_command_(CMD_GET_MODE); + break; + case BUTTON_GET_LOW_POWER_SLEEP_TIME: + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + break; + case BUTTON_RESET_UNATTENDED: + this->send_control_command_(CMD_RESET_UNATTENDED); + break; + default: + ESP_LOGW(TAG, "Unknown %s kind %u", LOG_STR_LITERAL("button"), kind); + break; + } +} +#endif // USE_BUTTON + +} // namespace esphome::ld600x diff --git a/esphome/components/ld600x/ld600x.h b/esphome/components/ld600x/ld600x.h new file mode 100644 index 00000000000..9df55074fc6 --- /dev/null +++ b/esphome/components/ld600x/ld600x.h @@ -0,0 +1,376 @@ +#pragma once + +// Shared base for the Hi-Link 60 GHz radars that speak the TinyFrame serial protocol: the LD6002B and +// the LD6004. The LD6001A uses AT commands and another report format and is a separate component. +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/ld600x/ld600x_frame.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/sensor/sensor_with_dedup.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#include +#include + +namespace esphome::ld600x { + +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * LD600X_AREA_KINDS; +static constexpr uint16_t DEFAULT_MAX_DATA_LEN = 1024; +static constexpr uint16_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; +// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. +static constexpr size_t CMD_MAX_DATA_LEN = 28; + +static constexpr uint8_t LD600X_MODEL_KIND_BASE = 0x80; + +enum AreaKind : uint8_t { AREA_KIND_INTERFERENCE, AREA_KIND_DETECTION, AREA_KIND_DWELL }; +enum AreaAxis : uint8_t { + AREA_AXIS_X_MIN, + AREA_AXIS_X_MAX, + AREA_AXIS_Y_MIN, + AREA_AXIS_Y_MAX, + AREA_AXIS_Z_MIN, + AREA_AXIS_Z_MAX, + AREA_AXIS_COUNT, +}; + +enum ButtonType : uint8_t { + BUTTON_APPLY_AREA, + BUTTON_AUTO_INTERFERENCE, + BUTTON_GET_AREAS, + BUTTON_CLEAR_INTERFERENCE, + BUTTON_RESET_DETECTION_AREA, + BUTTON_GET_DELAY, + BUTTON_GET_SENSITIVITY, + BUTTON_GET_TRIGGER_SPEED, + BUTTON_GET_Z_RANGE, + BUTTON_GET_INSTALLATION, + BUTTON_GET_LOW_POWER_MODE, + BUTTON_GET_LOW_POWER_SLEEP_TIME, + BUTTON_RESET_UNATTENDED, +}; + +#ifdef USE_SENSOR +struct TargetSensors { + sensor::Sensor *x{nullptr}; + sensor::Sensor *y{nullptr}; + sensor::Sensor *z{nullptr}; + sensor::Sensor *dop_idx{nullptr}; + // A cluster id names a person, so like the counts it is published on change, not per frame. + sensor::SensorWithDedup cluster_id; +}; + +// Indexed by AreaAxis. +using AreaSensors = std::array; +#endif + +// Index of one area in the tables that hold every area of every kind. +static constexpr uint8_t area_index(uint8_t kind, uint8_t area) { return kind * AREA_COUNT + area; } + +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + +struct VersionPref { + char value[20]; +}; + +class LD600XComponent : public Component, public uart::UARTDevice { + public: + // The tag comes from LOG_STR, so it stays in flash on ESP8266 like an ESPHOME_LOG_TAG. It keeps the + // model's own name on every log line, so a logger `logs:` filter on `ld6002b` keeps working. + explicit LD600XComponent(const LogString *log_tag) : log_tag_(LOG_STR_ARG(log_tag)) {} + + void setup() override; + void loop() override; + void dump_config() override; + +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(target_count, uint32_t) + SUB_SENSOR_WITH_DEDUP(point_count, uint32_t) + + void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->targets_[target].x = sensor; + } + void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->targets_[target].y = sensor; + } + void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->targets_[target].z = sensor; + } + void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->targets_[target].dop_idx = sensor; + } + void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->targets_[target].cluster_id.set_sensor(sensor); + } + void set_area_sensor(uint8_t kind, uint8_t area, uint8_t axis, sensor::Sensor *sensor) { + if (kind < LD600X_AREA_KINDS && area < AREA_COUNT && axis < AREA_AXIS_COUNT) + this->areas_[area_index(kind, area)][axis] = sensor; + } +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(presence) + void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { + if (target >= LD600X_MAX_TARGETS) + return; + this->target_presence_[target] = sensor; + } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(work_mode) + SUB_TEXT_SENSOR(ota_version) +#endif + +#ifdef USE_NUMBER + SUB_NUMBER(hold_delay) + SUB_NUMBER(z_min) + SUB_NUMBER(z_max) + SUB_NUMBER(low_power_sleep) + + SUB_NUMBER(area_x_min) + SUB_NUMBER(area_x_max) + SUB_NUMBER(area_y_min) + SUB_NUMBER(area_y_max) + SUB_NUMBER(area_z_min) + SUB_NUMBER(area_z_max) +#endif + +#ifdef USE_SELECT + SUB_SELECT(sensitivity) + SUB_SELECT(trigger_speed) + SUB_SELECT(installation) + SUB_SELECT(area_id) +#endif + +#ifdef USE_SWITCH + SUB_SWITCH(point_cloud) + SUB_SWITCH(target_display) +#endif + + // Each setter picks its setting by entity pointer, so the entities share one generated class. A model + // with its own entities handles those first and passes the rest down. +#ifdef USE_NUMBER + virtual void set_number_value(number::Number *entity, float value); +#endif +#ifdef USE_SELECT + 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); +#endif +#ifdef USE_BUTTON + virtual void press_button(uint8_t kind); +#endif + + protected: + virtual bool handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) { return false; } + // Whether one of the model's own entities reads the target stream, so boot switches it on. + virtual bool model_wants_target_stream() const { return false; } + // Called after every target or area presence report and whenever target presence is cleared. + virtual void on_presence_update() {} + // Lets the model hold a frame back (for example to wake the module first) and write it later with + // write_frame_(). Returns true when it took the frame. + virtual bool defer_frame_write(uint16_t type, const uint8_t *data, uint8_t len, bool track) { return false; } + virtual void setup_model() {} + virtual void dump_model_config() {} + + const char *log_tag_; + + struct PendingCommand { + uint16_t type{0}; + uint8_t len{0}; + std::array data{}; + }; + + void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); + void handle_target_report_(const uint8_t *data, uint16_t len); + void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(uint8_t kind, const uint8_t *data, uint16_t len); + void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_sensitivity_report_(const uint8_t *data, uint16_t len); + void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); + void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_installation_report_(const uint8_t *data, uint16_t len); + void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); + void handle_version_report_(const uint8_t *data, uint16_t len); + // Drops every target-derived reading and the slot table they are indexed by. + void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); +#ifdef USE_SENSOR + void clear_target_slot_(uint8_t index); +#endif +#ifdef USE_NUMBER + void publish_number_clamped_(number::Number *number, float value); +#endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(uint8_t reported_kind); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); + void init_version_pref_(); + void save_version_pref_(const char *value); + + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + void process_command_queue_(); + void send_command_(uint16_t type, const uint8_t *data, uint8_t len); + void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); + void send_z_range_(); + void apply_area_config_(); + +#ifdef USE_SENSOR + std::array targets_{}; + std::array areas_{}; +#endif +#ifdef USE_BINARY_SENSOR + std::array target_presence_{}; + std::array area_presence_{}; +#endif +#ifdef USE_TEXT_SENSOR + ESPPreferenceObject version_pref_{}; + bool version_pref_initialized_{false}; +#endif +#ifdef USE_SELECT + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; +#endif + + FrameParser parser_; + uint16_t next_frame_id_{0}; + + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; + static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; + // A sleeping module consumes the first frame to wake and answers only the one after it. + static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; + // How long the module stays awake after any frame, and so still answers the next one. + static constexpr uint32_t MODULE_AWAKE_MS = 10000; + static constexpr uint8_t CMD_MAX_RETRIES = 3; + // Free for one keyed timer of the model's own. + static constexpr uint32_t MODEL_TIMEOUT_ID = 0; + // Keyed so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr uint32_t AREA_REFRESH_TIMEOUT_ID = 1; + // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. + static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; + + StaticRingBuffer cmd_queue_; + bool command_active_{false}; + bool command_sent_{false}; + PendingCommand active_command_{}; + uint8_t retries_left_{0}; + uint32_t last_send_ms_{0}; + // Last frame seen in either direction; any traffic keeps the module awake. + uint32_t last_traffic_ms_{0}; + // Frames transmitted for the command in flight, including retries; drives the retry budget. + uint8_t attempts_sent_{0}; + // Subset of those the module can actually answer: a frame that woke it is consumed, not replied to. + uint8_t acks_expected_{0}; + // ACKs still owed for superseded attempts; they carry no id, only their arrival order. + uint16_t stale_ack_type_{0}; + uint8_t stale_ack_count_{0}; + // When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK. + uint32_t stale_ack_ms_{0}; + // Bumped whenever the active command changes, so a deferred send can tell it was retired. + uint8_t send_generation_{0}; + + float z_min_{NAN}; + float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; + + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. + std::array slot_cluster_{}; + std::array slot_occupied_{}; + + bool target_presence_any_{false}; + // What the switches and setup asked the module for, which is not the same as + // what it is doing yet: a stream keeps sending until it acts on the command. + // The report handlers read these and drop anything a stopped stream still emits. + bool target_display_enabled_{false}; + bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; + +#ifdef USE_SENSOR + std::array last_target_presence_{}; // one-shot NAN clear for target sensors +#endif +}; + +} // namespace esphome::ld600x diff --git a/esphome/components/ld600x/ld600x_frame.cpp b/esphome/components/ld600x/ld600x_frame.cpp new file mode 100644 index 00000000000..11d28ee4ec0 --- /dev/null +++ b/esphome/components/ld600x/ld600x_frame.cpp @@ -0,0 +1,104 @@ +#include "ld600x_frame.h" + +#include "esphome/core/log.h" + +#include + +namespace esphome::ld600x { + +ESPHOME_LOG_TAG(TAG, "ld600x"); + +void FrameParser::init(uint8_t *buffer, uint16_t capacity) { + this->data_buf_ = buffer; + this->max_data_len_ = capacity; + this->reset(); +} + +void FrameParser::start_header_() { + this->pos_ = 0; + this->xor_ = TF_SOF; + this->parse_state_ = ParseState::PARSE_STATE_HEADER; +} + +bool FrameParser::feed(uint8_t byte) { + switch (this->parse_state_) { + case ParseState::PARSE_STATE_DISCARD: + // pos_ counts the payload down; the byte after it is the data checksum. + if (this->pos_ == 0) { + this->reset(); + } else { + this->pos_--; + } + return false; + case ParseState::PARSE_STATE_SOF: + if (byte == TF_SOF) + this->start_header_(); + return false; + case ParseState::PARSE_STATE_HEADER: { + // Big-endian id, length and type, shifted in as they arrive so the buffer only ever holds a payload + uint16_t &field = this->pos_ < 2 ? this->frame_id_ : this->pos_ < 4 ? this->data_len_ : this->frame_type_; + field = static_cast((field << 8) | byte); + this->xor_ ^= byte; + if (++this->pos_ == TF_HEADER_LEN) + this->parse_state_ = ParseState::PARSE_STATE_HCK; + return false; + } + case ParseState::PARSE_STATE_DATA: + this->data_buf_[this->pos_++] = byte; + this->xor_ ^= byte; + if (this->pos_ >= this->data_len_) { + this->parse_state_ = ParseState::PARSE_STATE_DCK; + } + return false; + case ParseState::PARSE_STATE_HCK: + case ParseState::PARSE_STATE_DCK: { + const bool header = this->parse_state_ == ParseState::PARSE_STATE_HCK; + if (byte != static_cast(~this->xor_)) { + ESP_LOGV(TAG, "%s checksum mismatch", header ? LOG_STR_LITERAL("Header") : LOG_STR_LITERAL("Data")); + // The byte may be the SOF of the next frame when the real checksum was lost on the wire. + if (byte == TF_SOF) { + this->start_header_(); + } else { + this->reset(); + } + return false; + } + if (!header || this->data_len_ == 0) { + this->reset(); + return true; + } + if (this->data_len_ > this->max_data_len_) { + ESP_LOGW(TAG, "Frame too large: %u", static_cast(this->data_len_)); + this->pos_ = this->data_len_; + this->parse_state_ = ParseState::PARSE_STATE_DISCARD; + return false; + } + this->pos_ = 0; + this->xor_ = 0; + this->parse_state_ = ParseState::PARSE_STATE_DATA; + return false; + } + } + return false; +} + +size_t encode_frame(uint16_t frame_id, uint16_t type, const uint8_t *data, uint16_t len, uint8_t *out) { + out[0] = TF_SOF; + out[1] = frame_id >> 8; + out[2] = frame_id & 0xFF; + out[3] = len >> 8; + out[4] = len & 0xFF; + out[5] = type >> 8; + out[6] = type & 0xFF; + // TinyFrame checksums are the inverted XOR of the bytes they cover, SOF included for the header. + out[TF_HEADER_LEN + 1] = static_cast(~xor_bytes(out, TF_HEADER_LEN + 1)); + size_t pos = TF_HEADER_LEN + 2; + if (len > 0 && data != nullptr) { + std::memcpy(out + pos, data, len); + pos += len; + out[pos++] = static_cast(~xor_bytes(data, len)); + } + return pos; +} + +} // namespace esphome::ld600x diff --git a/esphome/components/ld600x/ld600x_frame.h b/esphome/components/ld600x/ld600x_frame.h new file mode 100644 index 00000000000..0d35f7a443a --- /dev/null +++ b/esphome/components/ld600x/ld600x_frame.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::ld600x { + +static constexpr uint8_t TF_SOF = 0x01; +// ID, payload length and type, excluding SOF and the header checksum. +static constexpr uint8_t TF_HEADER_LEN = 6; +// SOF, header, header checksum and data checksum around a payload. +static constexpr uint8_t TF_FRAME_OVERHEAD = TF_HEADER_LEN + 3; + +inline float read_f32_le(const uint8_t *data) { return bit_cast(encode_value_le(data)); } + +inline void write_u32_le(uint8_t *data, uint32_t value) { + data[0] = value & 0xFF; + data[1] = (value >> 8) & 0xFF; + data[2] = (value >> 16) & 0xFF; + data[3] = (value >> 24) & 0xFF; +} + +inline void write_f32_le(uint8_t *data, float value) { write_u32_le(data, bit_cast(value)); } + +class FrameParser final { + public: + // The caller owns the buffer; payloads longer than capacity are skipped. + void init(uint8_t *buffer, uint16_t capacity); + bool feed(uint8_t byte); + uint16_t type() const { return this->frame_type_; } + uint16_t id() const { return this->frame_id_; } + uint16_t length() const { return this->data_len_; } + const uint8_t *data() const { return this->data_len_ == 0 ? nullptr : this->data_buf_; } + uint16_t capacity() const { return this->max_data_len_; } + void reset() { this->parse_state_ = ParseState::PARSE_STATE_SOF; } + + protected: + enum class ParseState : uint8_t { + PARSE_STATE_SOF, + PARSE_STATE_HEADER, + PARSE_STATE_HCK, + PARSE_STATE_DATA, + PARSE_STATE_DCK, + PARSE_STATE_DISCARD, + }; + + void start_header_(); + + uint8_t *data_buf_{nullptr}; + uint16_t data_len_{0}; + uint16_t frame_type_{0}; + uint16_t frame_id_{0}; + // Header or payload write position, or the payload bytes left to discard. + uint16_t pos_{0}; + uint16_t max_data_len_{0}; + ParseState parse_state_{ParseState::PARSE_STATE_SOF}; + uint8_t xor_{0}; +}; + +// Frames this side sends have the top id bit set; the low 15 bits count up. +inline uint16_t next_frame_id(uint16_t &counter) { return (counter++ & 0x7FFF) | 0x8000; } + +// Out must hold TF_FRAME_OVERHEAD + len bytes. Empty payloads have no data checksum. +size_t encode_frame(uint16_t frame_id, uint16_t type, const uint8_t *data, uint16_t len, uint8_t *out); + +} // namespace esphome::ld600x diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index 0a6d49d1736..0cb1f93ae4d 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -192,7 +192,7 @@ uint32_t MAX31856Sensor::read_register24_(uint8_t reg) { const uint8_t lsb(this->read_byte()); ESP_LOGVV(TAG, "read_byte lsb=0x%02X", lsb); this->disable(); - const uint32_t value((msb << 16) | (mid << 8) | lsb); + const uint32_t value = encode_uint24(msb, mid, lsb); ESP_LOGV(TAG, "read_register_24_ reg=0x%02X: value=0x%06" PRIX32, reg, value); return value; } diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index 94899b05ab5..0c772bfff11 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -1,6 +1,7 @@ #include "max31865.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include #include @@ -184,7 +185,7 @@ uint16_t MAX31865Sensor::read_register_16_(uint8_t reg) { const uint8_t msb(this->read_byte()); const uint8_t lsb(this->read_byte()); this->disable(); - const uint16_t value((msb << 8) | lsb); + const uint16_t value = encode_uint16(msb, lsb); ESP_LOGVV(TAG, "read_register_16_ 0x%02X: 0x%04X", reg, value); return value; } diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index cc6b5ea5c94..3ad7615b368 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -60,9 +60,12 @@ void MAX9611Component::update() { ESP_LOGW(TAG, "Update failed"); return; } - uint16_t csa_register = ((register_map_[CSA_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[CSA_DATA_BYTE_LSB_ADRR])) >> 4; - uint16_t rs_register = ((register_map_[RS_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[RS_DATA_BYTE_LSB_ADRR])) >> 4; - uint16_t t_register = ((register_map_[TEMP_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[TEMP_DATA_BYTE_LSB_ADRR])) >> 7; + uint16_t csa_register = + encode_uint16(this->register_map_[CSA_DATA_BYTE_MSB_ADRR], this->register_map_[CSA_DATA_BYTE_LSB_ADRR]) >> 4; + uint16_t rs_register = + encode_uint16(this->register_map_[RS_DATA_BYTE_MSB_ADRR], this->register_map_[RS_DATA_BYTE_LSB_ADRR]) >> 4; + uint16_t t_register = + encode_uint16(this->register_map_[TEMP_DATA_BYTE_MSB_ADRR], this->register_map_[TEMP_DATA_BYTE_LSB_ADRR]) >> 7; float voltage = rs_register * VOUT_LSB; float shunt_voltage = (csa_register * gain_to_lsb(gain_)) / MICRO_VOLTS_PER_VOLT; float temp = sign_extend<9>(t_register) * TEMP_LSB; diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index cca5152ad35..aff80a0a171 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -1,5 +1,7 @@ #include "mhz19.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #include @@ -44,12 +46,15 @@ void MHZ19Component::setup() { } void MHZ19Component::update() { - uint32_t now_ms = millis(); - uint32_t warmup_ms = this->warmup_seconds_ * 1000; - if (now_ms < warmup_ms) { - ESP_LOGW(TAG, "MHZ19 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000); - this->status_set_warning(); - return; + if (!this->warmup_complete_) { + uint32_t now_ms = App.get_loop_component_start_time(); + uint32_t warmup_ms = this->warmup_seconds_ * 1000; + if (now_ms < warmup_ms) { + ESP_LOGW(TAG, "MHZ19 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000); + this->status_set_warning(); + return; + } + this->warmup_complete_ = true; } uint8_t response[MHZ19_RESPONSE_LENGTH]; @@ -71,7 +76,7 @@ void MHZ19Component::update() { return; } - const uint16_t ppm = (uint16_t(response[2]) << 8) | response[3]; + const uint16_t ppm = encode_uint16(response[2], response[3]); const int temp = int(response[4]) - 40; const uint8_t status = response[5]; diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index af0b7a475b3..a6d503a35a2 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -6,13 +6,13 @@ namespace esphome::mhz19 { -enum MHZ19ABCLogic { +enum MHZ19ABCLogic : uint8_t { MHZ19_ABC_NONE = 0, MHZ19_ABC_ENABLED, MHZ19_ABC_DISABLED, }; -enum MHZ19DetectionRange { +enum MHZ19DetectionRange : uint8_t { MHZ19_DETECTION_RANGE_DEFAULT = 0, MHZ19_DETECTION_RANGE_0_2000PPM, MHZ19_DETECTION_RANGE_0_5000PPM, @@ -40,11 +40,10 @@ class MHZ19Component final : public PollingComponent, public uart::UARTDevice { protected: bool mhz19_write_command_(const uint8_t *command, uint8_t *response); - MHZ19ABCLogic abc_boot_logic_{MHZ19_ABC_NONE}; - uint32_t warmup_seconds_; - + MHZ19ABCLogic abc_boot_logic_{MHZ19_ABC_NONE}; MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; + bool warmup_complete_{false}; }; } // namespace esphome::mhz19 diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3c5fff16ece..093d6380511 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -229,7 +229,7 @@ def map_sequence(value): The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred from the length of the sequence and should not be explicit. A single integer can be provided where there are no data bytes, in which case it is treated as a command. - A delay can be inserted by specifying "- delay N" where N is in ms + A delay can be inserted by specifying "- delay Nms"; any time unit is accepted, from 1 to 255 ms """ if isinstance(value, str) and value.lower().startswith("delay "): value = value.lower()[6:] @@ -676,7 +676,7 @@ class DriverChip: sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] if add_reset: - # Matches the 1-255ms range map_sequence() already allows for a "delay N" entry. + # Matches the 1-255ms range map_sequence() already allows for a "delay Nms" entry. reset_delay = self.get_default("reset_delay", 10) if reset_delay < 1 or reset_delay > 255: raise ValueError("reset_delay must be between 1 and 255ms") diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index 28b3f416497..fa8a2e73db3 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -58,7 +58,8 @@ t_rgb = ST7701S( spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, requires={"psram"}, - de_pin=45, + de_pin={"number": 45, "ignore_strapping_warning": True}, + enable_pin={"number": 46, "ignore_strapping_warning": True}, hsync_pin=47, vsync_pin=41, pclk_pin=42, diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index f5760e75228..03cdb9ca571 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -411,7 +411,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { - Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: if (!decoder.decode_settings(this->status_)) { @@ -513,7 +513,7 @@ void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { void MitsubishiCN105::apply_settings_() { std::array payload{}; - Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; if (this->pending_updates_.contains(PropertyId::POWER) || this->pending_updates_.contains(PropertyId::MODE)) { this->status_changed_ |= this->hold_if_run_ended_(this->unsent_from_power_on_, this->unsent_from_mode_); this->request_telemetry_refresh_(); diff --git a/esphome/components/mqtt/mqtt_date.cpp b/esphome/components/mqtt/mqtt_date.cpp index 26d9c6a4517..1528dc9cd08 100644 --- a/esphome/components/mqtt/mqtt_date.cpp +++ b/esphome/components/mqtt/mqtt_date.cpp @@ -40,7 +40,12 @@ MQTT_COMPONENT_TYPE(MQTTDateComponent, "date") const EntityBase *MQTTDateComponent::get_entity() const { return this->date_; } void MQTTDateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { - // Nothing extra to add here + // Home Assistant sends and expects ISO "YYYY-MM-DD", while state and command payloads here are JSON + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + root[MQTT_VALUE_TEMPLATE] = + ESPHOME_F("{{ '%04d-%02d-%02d' | format(value_json.year, value_json.month, value_json.day) }}"); + root[MQTT_COMMAND_TEMPLATE] = + ESPHOME_F("{\"year\":{{ value.year }},\"month\":{{ value.month }},\"day\":{{ value.day }}}"); } bool MQTTDateComponent::send_initial_state() { if (this->date_->has_state()) { diff --git a/esphome/components/mqtt/mqtt_datetime.cpp b/esphome/components/mqtt/mqtt_datetime.cpp index 81cd5fb32db..76d703e1854 100644 --- a/esphome/components/mqtt/mqtt_datetime.cpp +++ b/esphome/components/mqtt/mqtt_datetime.cpp @@ -51,7 +51,15 @@ MQTT_COMPONENT_TYPE(MQTTDateTimeComponent, "datetime") const EntityBase *MQTTDateTimeComponent::get_entity() const { return this->datetime_; } void MQTTDateTimeComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { - // Nothing extra to add here + // Home Assistant requires timezone-aware ISO datetimes; the device works in local time, which is + // assumed to match the Home Assistant time zone + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + root[MQTT_VALUE_TEMPLATE] = + ESPHOME_F("{{ ('%04d-%02d-%02dT%02d:%02d:%02d' | format(value_json.year, value_json.month, value_json.day, " + "value_json.hour, value_json.minute, value_json.second)) | as_datetime | as_local }}"); + root[MQTT_COMMAND_TEMPLATE] = + ESPHOME_F("{% set v = value | as_local %}{\"year\":{{ v.year }},\"month\":{{ v.month }},\"day\":{{ v.day }}," + "\"hour\":{{ v.hour }},\"minute\":{{ v.minute }},\"second\":{{ v.second }}}"); } bool MQTTDateTimeComponent::send_initial_state() { if (this->datetime_->has_state()) { diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp index e40a7aa0e3b..bcd2a0c9f28 100644 --- a/esphome/components/mqtt/mqtt_time.cpp +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -40,7 +40,12 @@ MQTT_COMPONENT_TYPE(MQTTTimeComponent, "time") const EntityBase *MQTTTimeComponent::get_entity() const { return this->time_; } void MQTTTimeComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { - // Nothing extra to add here + // Home Assistant sends and expects ISO "HH:MM:SS", while state and command payloads here are JSON + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + root[MQTT_VALUE_TEMPLATE] = + ESPHOME_F("{{ '%02d:%02d:%02d' | format(value_json.hour, value_json.minute, value_json.second) }}"); + root[MQTT_COMMAND_TEMPLATE] = + ESPHOME_F("{\"hour\":{{ value.hour }},\"minute\":{{ value.minute }},\"second\":{{ value.second }}}"); } bool MQTTTimeComponent::send_initial_state() { if (this->time_->has_state()) { diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.cpp b/esphome/components/mt6701_i2c/mt6701_i2c.cpp index cf1094ee225..3c294c19719 100644 --- a/esphome/components/mt6701_i2c/mt6701_i2c.cpp +++ b/esphome/components/mt6701_i2c/mt6701_i2c.cpp @@ -25,10 +25,6 @@ void MT6701I2CComponent::setup() { void MT6701I2CComponent::dump_config() { ESP_LOGCONFIG(TAG, "MT6701 (I2C):"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - return; - } if (this->direction_ != UNSET_U8) { ESP_LOGCONFIG(TAG, " Direction bit: %u", this->direction_); } diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index c56094c3739..0c4b6174887 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -54,7 +54,6 @@ using ip4_addr_t = in_addr; #ifdef USE_ZEPHYR #include #include -#include #ifndef CONFIG_NET_IPV6 #error "Zephyr builds need CONFIG_NET_IPV6" #endif @@ -64,12 +63,12 @@ static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { // Dual-stack: a plain IPv4 literal must still parse -- store it as a v4-mapped // IPv6 address (::ffff:a.b.c.d), matching the 4-octet constructor below. struct in_addr addr4; - if (inet_pton(AF_INET, cp, &addr4) == 1) { + if (net_addr_pton(AF_INET, cp, &addr4) == 0) { net_ipv6_addr_create_v4_mapped(&addr4, addr); return 1; } #endif /* CONFIG_NET_IPV4 */ - return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; + return net_addr_pton(AF_INET6, cp, addr) == 0 ? 1 : 0; } #endif /* USE_ZEPHYR */ @@ -90,7 +89,7 @@ namespace esphome::network { /// Buffer size for IP address string (IPv6 max: 39 chars + null) static constexpr size_t IP_ADDRESS_BUFFER_SIZE = #if defined(USE_ZEPHYR) - // Mainline Zephyr's inet_ntop() (net_addr_ntop() in subsys/net/ip/utils.c) never checks + // Mainline Zephyr's net_addr_ntop() (subsys/net/ip/utils.c) never checks // its size argument, so an undersized buffer overflows instead of failing cleanly. Size // for the worst case: an IPv4-mapped IPv6 address needs up to INET6_ADDRSTRLEN (46). 46; @@ -155,12 +154,12 @@ struct IPAddress { if (this->is_ip4()) { struct in_addr addr4{}; addr4.s_addr = ip_addr_.s6_addr32[3]; - if (inet_ntop(AF_INET, &addr4, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) + if (net_addr_ntop(AF_INET, &addr4, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) buf[0] = '\0'; return buf; } #endif /* CONFIG_NET_IPV4 */ - if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) + if (net_addr_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) buf[0] = '\0'; return buf; } diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 03703bb4216..4314a66cdfc 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -49,7 +49,7 @@ bool decode_mifare_classic_tlv(const std::span data, uint32_t &me } uint8_t idx = static_cast(i); if (idx + 4 <= data.size() && data[idx + 1] == 0xFF) { - message_length = ((0xFF & data[idx + 2]) << 8) | (0xFF & data[idx + 3]); + message_length = encode_uint16(data[idx + 2], data[idx + 3]); message_start_index = idx + MIFARE_CLASSIC_LONG_TLV_SIZE; } else if (idx + 2 <= data.size()) { message_length = data[idx + 1]; diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index 439b0895952..d55fa29230c 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -44,10 +44,10 @@ i2c::ErrorCode NPI19Component::read_(uint16_t &raw_temperature, uint16_t &raw_pr } // extract top 6 bits of first byte and all bits of second byte for pressure - raw_pressure = ((response[0] & 0x3F) << 8) | response[1]; + raw_pressure = encode_uint16(response[0] & 0x3F, response[1]); // extract all bytes of 3rd byte and top 3 bits of fourth byte for temperature - raw_temperature = (response[2] << 3) | ((response[3] & 0xE0) >> 5); + raw_temperature = encode_uint16(response[2], response[3]) >> 5; return i2c::ERROR_OK; } diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 70524c0bcdd..32203bfbbd5 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -78,6 +78,10 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, BOOTLOADER_NRF, + CONF_TRANSPORT, + CONF_UDP, + FLASH_SECTOR_SIZE, + SMP_UDP_PORT, ) from .framework import ( check_and_install, @@ -756,6 +760,15 @@ def _enter_open_dfu(host: str) -> str: ) +def has_network_ota(config: ConfigType) -> bool: + """True when zephyr_mcumgr OTA listens on UDP.""" + return any( + ota.get(CONF_PLATFORM) == "zephyr_mcumgr" + and ota.get(CONF_TRANSPORT, {}).get(CONF_UDP) + for ota in config.get(CONF_OTA, []) + ) + + def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type @@ -855,32 +868,91 @@ def upload_program(config: ConfigType, args, host: str) -> bool: raise EsphomeError(f"Upload failed with result: {result}") return True # Handled: PYOCD upload + file = getattr(args, "file", None) if mcumgr_device: # MCUboot over USB CDC: the serial port speaks the MCUmgr SMP protocol - asyncio.run(_smpmgr_upload(mcumgr_device)) + asyncio.run(_smpmgr_upload(mcumgr_device, file)) return True # Handled: mcumgr serial upload # Deferred imports: bleak and friends are heavy, only load for BLE paths from .ble_logger import is_ble_address if host == "BLE" or is_ble_address(host): - asyncio.run(_upload_over_ble(config, host)) + asyncio.run(_upload_over_ble(config, host, file)) return True # Handled: BLE OTA upload + if get_port_type(host) == PortType.NETWORK and has_network_ota(config): + ipv4 = config.get(network.DOMAIN, {}).get(network.CONF_ENABLE_IPV4, False) + addresses = _resolve_smp_udp_hosts(host, ipv4) + asyncio.run(_upload_over_udp(host, addresses, file)) + return True # Handled: mcumgr UDP upload + return False # Not handled: let caller try default upload methods -async def _smpmgr_upload(device: str) -> None: - """Upload the MCUboot application image over the MCUmgr SMP protocol.""" +async def _smpmgr_upload( + device: str, file: str | None = None, udp: bool = False +) -> None: + """Upload the MCUboot application image (or --file) over the MCUmgr SMP protocol.""" from .ota import smpmgr_upload firmware = Path( - CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin") + file or CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin") ).resolve() - await smpmgr_upload(device, firmware) + await smpmgr_upload(device, firmware, udp) -async def _upload_over_ble(config: ConfigType, host: str) -> None: +def _resolve_smp_udp_hosts(host: str, ipv4: bool) -> list[str]: + """Every address for the host through the address cache, in resolve_ip_address order. + + The device listens on IPv6, and on IPv4 only with network enable_ipv4. + """ + import socket + + from esphome.helpers import resolve_ip_address + + # The CLI hands over only the first address it expanded from the cache + cache = CORE.address_cache + if cache and CORE.address and host in (cache.get_addresses(CORE.address) or ()): + host = CORE.address + resolved = resolve_ip_address(host, SMP_UDP_PORT, cache) + if not resolved: + raise EsphomeError(f"No addresses to connect to for {host}") + if not ipv4: + resolved = [addr for addr in resolved if addr[0] == socket.AF_INET6] + if not resolved: + raise EsphomeError( + f"{host} has no IPv6 address; the device listens for OTA on IPv6 " + "unless 'network: enable_ipv4: true' is set" + ) + addresses: list[str] = [] + for family, _, _, _, sockaddr in resolved: + address = sockaddr[0] + if family == socket.AF_INET6 and sockaddr[3]: + address = f"{address}%{sockaddr[3]}" + if address not in addresses: + addresses.append(address) + return addresses + + +async def _upload_over_udp(host: str, addresses: list[str], file: str | None) -> None: + """Try each address until one answers; a failure after that is final.""" + from .ota_udp import SMPUnreachableError + + for index, address in enumerate(addresses, 1): + _LOGGER.info("Uploading to %s at %s", host, address) + try: + await _smpmgr_upload(address, file, udp=True) + return + except SMPUnreachableError as err: + if index == len(addresses): + raise + _LOGGER.warning("%s, trying the next address", err) + + +async def _upload_over_ble( + config: ConfigType, host: str, file: str | None = None +) -> None: """Upload over the OTA protocol the firmware was built with.""" from .ota import smpmgr_scan @@ -890,7 +962,7 @@ async def _upload_over_ble(config: ConfigType, host: str) -> None: ota.get(CONF_PLATFORM) == "adafruit_ble" for ota in config.get(CONF_OTA, []) ): address = await smpmgr_scan(CORE.name) if host == "BLE" else host - await _smpmgr_upload(address) + await _smpmgr_upload(address, file) return from esphome.components.adafruit_ble import dfu @@ -1125,6 +1197,74 @@ def _copy_if_exists(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +# nRF52 flash write size and MCUboot's trailer layout (bootutil_misc.c) +FLASH_WRITE_SIZE = 4 +MCUBOOT_SWAP_MOVE_STATES = 3 +MCUBOOT_TRAILER_INFO_SIZE = 4 * 8 + 16 # swap info fields and magic + + +def _mcuboot_swap_limit(partitions: dict, mcuboot_config: str) -> int | None: + """The largest image MCUboot's swap using move can swap in, or None for other modes. + + Mirrors app_max_size() in swap_move.c: the primary slot less its trailer sectors + and the sector the move needs, and no more than the secondary slot. + """ + if "CONFIG_BOOT_SWAP_USING_MOVE=y" not in mcuboot_config.splitlines(): + return None + match = re.search( + r"^CONFIG_BOOT_MAX_IMG_SECTORS=(\d+)$", mcuboot_config, re.MULTILINE + ) + if match is None: + _LOGGER.warning( + "Skipping the MCUboot slot size check: BOOT_MAX_IMG_SECTORS is not set" + ) + return None + trailer = ( + int(match.group(1)) * MCUBOOT_SWAP_MOVE_STATES * FLASH_WRITE_SIZE + + MCUBOOT_TRAILER_INFO_SIZE + ) + trailer_sectors = -(-trailer // FLASH_SECTOR_SIZE) + primary_sectors = partitions["mcuboot_primary"]["size"] // FLASH_SECTOR_SIZE + primary = (primary_sectors - trailer_sectors - 1) * FLASH_SECTOR_SIZE + return min(primary, partitions["mcuboot_secondary"]["size"]) + + +def _check_mcuboot_swap_limit(build_dir: Path, image: Path) -> None: + """Fail a build whose image MCUboot would refuse to swap in. + + The linker only checks the app partition. MCUboot then boots the old image and + leaves the new one pending, which blocks every later OTA update. + """ + partitions_file = build_dir / "partitions.yml" + mcuboot_config = build_dir / "mcuboot" / "zephyr" / ".config" + if not image.is_file(): + # A stale app_update.bin from an earlier build could otherwise go out unnoticed + _LOGGER.warning("Skipping the MCUboot slot size check: %s is missing", image) + return + if not (partitions_file.is_file() and mcuboot_config.is_file()): + _LOGGER.warning( + "Skipping the MCUboot slot size check: %s or %s is missing", + partitions_file, + mcuboot_config, + ) + return + import yaml + + limit = _mcuboot_swap_limit( + yaml.safe_load(partitions_file.read_text()), mcuboot_config.read_text() + ) + if limit is None: + return + size = image.stat().st_size + if size > limit: + raise EsphomeError( + f"The application image is {size} bytes, but MCUboot can only swap in " + f"{limit} bytes on this board. Remove at least {size - limit} bytes, for " + "example by leaving out components or lowering the log level." + ) + _LOGGER.info("Application image: %d of %d bytes MCUboot can swap in", size, limit) + + def _west_build_command( python_executable: Path, board: str, build_dir: Path, source_dir: Path ) -> list[str]: @@ -1230,6 +1370,9 @@ def run_compile(args, config: ConfigType) -> bool: west_out = zephyr_dir else: west_out = zephyr_dir / "zephyr" + if zephyr_data().get(KEY_SYSBUILD): + # Before the copies, so a failed check leaves no oversized app_update.bin + _check_mcuboot_swap_limit(build_dir, west_out / "zephyr.signed.bin") _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") _copy_if_exists(west_out / "zephyr.hex", zephyr_dir / "zephyr.hex") diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index 166e79bacc6..16720ae4d32 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -18,3 +18,17 @@ AIN_TO_GPIO = { "AIN6": 30, "AIN7": 31, } + +FLASH_SECTOR_SIZE = 0x1000 # nRF52 flash page + +# zephyr_mcumgr OTA keys, shared with the upload path +CONF_TRANSPORT = "transport" +CONF_UDP = "udp" + +# SMP over UDP fits each frame in Thread's IPv6 minimum link MTU. The host passes it to +# smpclient, which subtracts the IP and UDP headers; the device sizes its receive buffer +# for the largest payload that leaves. The port and header sizes match smpclient's. +SMP_UDP_LINK_MTU = 1280 +SMP_UDP_PORT = 1337 # smpclient and MCUMGR_TRANSPORT_UDP_PORT default +IPV4_UDP_HEADERS = 28 +IPV6_UDP_HEADERS = 48 diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index 9115e218747..74c55aabba0 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -1,5 +1,7 @@ import asyncio +from collections.abc import Callable from dataclasses import asdict +from functools import partial import json import logging from pathlib import Path @@ -7,7 +9,7 @@ from pathlib import Path from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient -from smpclient.generics import error, success +from smpclient.generics import SMPRequest, error, success from smpclient.mcuboot import IMAGE_TLV, ImageInfo, MCUBootImageError, TLVNotFound from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite from smpclient.requests.os_management import ResetWrite @@ -23,6 +25,7 @@ from esphome.core import EsphomeError from esphome.espota2 import ProgressBar from .ble_logger import find_device_by_name, is_ble_address +from .const import SMP_UDP_LINK_MTU, SMP_UDP_PORT BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -51,9 +54,12 @@ async def smpmgr_scan(name: str) -> str: return device.address -async def smpmgr_upload(device: str, firmware: Path) -> None: +async def smpmgr_upload(device: str, firmware: Path, udp: bool = False) -> None: + from .ota_udp import _quiet_smpclient_timeouts + try: - await _smpmgr_upload(device, firmware) + with _quiet_smpclient_timeouts(udp): + await _smpmgr_upload(device, firmware, udp) except SMPTransportDisconnected as exc: raise EsphomeError(f"{device} was disconnected.") from exc except SMPBLETransportDeviceNotFound as exc: @@ -83,10 +89,23 @@ def _get_image_tlv_sha256(file: Path) -> bytes: return image_tlv_sha256.value -async def _smpmgr_upload(device: str, firmware: Path) -> None: +async def _smpmgr_upload(device: str, firmware: Path, udp: bool) -> None: + from .ota_udp import ( + SMP_UDP_PROBE_TIMEOUT, + SMPUnreachableError, + _SequenceMatchingUDPTransport, + ) + image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_ble_address(device): + if udp: + smp_client = SMPClient( + _SequenceMatchingUDPTransport(mtu=SMP_UDP_LINK_MTU), + device, + # Short, so a dead address fails fast; uploads pass their own timeout + timeout_s=SMP_UDP_PROBE_TIMEOUT, + ) + elif is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -94,6 +113,10 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: _LOGGER.info("Connecting %s...", device) try: await smp_client.connect() + except OSError as exc: + if not udp: + raise + raise SMPUnreachableError(f"Cannot reach {device}: {exc}") from exc except BleakDeviceNotFoundError as exc: raise EsphomeError(f"Device {device} not found") from exc except BleakDBusError as exc: @@ -108,17 +131,78 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: _LOGGER.info("Connected %s...", device) try: - await _smpmgr_upload_connected(smp_client, device, firmware, image_tlv_sha256) + await _smpmgr_upload_connected( + smp_client, device, firmware, image_tlv_sha256, udp + ) finally: await smp_client.disconnect() -async def _smpmgr_upload_connected( - smp_client: SMPClient, device: str, firmware: Path, image_tlv_sha256: bytes -) -> None: +def _find_image(state: object, image_hash: bytes) -> object | None: + """Our image in an image state reply, or None.""" + return next((image for image in state.images if image.hash == image_hash), None) + + +async def _try_request( + smp_client: SMPClient, request: SMPRequest, timeout_s: float | None +) -> object | None: + """The reply, or None when none arrives in time.""" try: - image_state = await smp_client.request(ImageStatesRead(), 2.5) - except (SMPBadStartDelimiter, TimeoutError) as exc: + return await smp_client.request(request, timeout_s) + except TimeoutError: + return None + + +async def _request_with_retry( + smp_client: SMPClient, + make_request: Callable[[], SMPRequest], + timeout_s: float | None, + retries: int, + target: str, +): + """Send a fresh request (new sequence number) each attempt until one is answered.""" + for attempt in range(retries + 1): + if ( + reply := await _try_request(smp_client, make_request(), timeout_s) + ) is not None: + return reply + if attempt < retries: + _LOGGER.debug("No reply, resending (%d/%d)", attempt + 1, retries) + name = getattr(make_request, "func", make_request).__name__ + raise EsphomeError( + f"{target} could not be reached: no reply to {name} after {retries + 1} attempts" + ) + + +async def _smpmgr_upload_connected( + smp_client: SMPClient, + device: str, + firmware: Path, + image_tlv_sha256: bytes, + udp: bool, +) -> None: + from .ota_udp import ( + SMP_UDP_REQUEST_TIMEOUT, + SMP_UDP_STATE_RETRIES, + SMPUnreachableError, + _udp_reset, + _udp_upload, + ) + + # UDP state reads use the client's short timeout; BLE and serial keep their tuned ones + read_timeout, write_timeout, retries = ( + (None, SMP_UDP_REQUEST_TIMEOUT, SMP_UDP_STATE_RETRIES) if udp else (2.5, 1.0, 0) + ) + target = f"{device} on UDP port {SMP_UDP_PORT}" if udp else device + try: + image_state = await _request_with_retry( + smp_client, ImageStatesRead, read_timeout, retries, target + ) + except SMPBadStartDelimiter as exc: + raise EsphomeError(f"mcumgr is not supported by device ({device})") from exc + except EsphomeError as exc: + if udp: + raise SMPUnreachableError(str(exc)) from exc raise EsphomeError(f"mcumgr is not supported by device ({device})") from exc already_uploaded = False @@ -126,22 +210,32 @@ async def _smpmgr_upload_connected( if error(image_state): raise EsphomeError(f"Failed to read image state from {device}: {image_state}") if success(image_state): - if len(image_state.images) == 0: + images = image_state.images + if len(images) == 0: _LOGGER.warning("No images on device!") - for image in image_state.images: + for image in images: _LOGGER.info( "Image state:\n%s", json.dumps(image, indent=2, default=_json_state), ) if image.active and not image.confirmed: raise EsphomeError("No free slot. Testing mode but not confirmed yet.") - if image.hash == image_tlv_sha256: - if already_uploaded: - raise EsphomeError("Both slots have the same image already") - if image.confirmed: - raise EsphomeError("The same image already confirmed") - _LOGGER.warning("The same image already uploaded") - already_uploaded = True + if sum(image.hash == image_tlv_sha256 for image in images) > 1: + raise EsphomeError("Both slots have the same image already") + if (ours := _find_image(image_state, image_tlv_sha256)) is not None: + if ours.confirmed: + raise EsphomeError("The same image already confirmed") + _LOGGER.warning("The same image already uploaded") + already_uploaded = True + elif any(image.pending and not image.active for image in images): + # The device refuses to erase or overwrite a pending slot (NO_FREE_SLOT). + # The image states carry no size, so the cause cannot be told from here. + raise EsphomeError( + f"{device} still has an earlier update pending. Restart the device to " + "apply it; if it then still runs the old firmware, the image was probably " + "too large for the MCUboot slot and the device must be flashed once over " + "USB to clear it." + ) if not already_uploaded: with firmware.open("rb") as file: @@ -150,20 +244,33 @@ async def _smpmgr_upload_connected( progress = ProgressBar("Uploading") progress.update(0) try: - async for offset in smp_client.upload(image): - progress.update(offset / upload_size) + if udp: + await _udp_upload( + smp_client, image, progress, target, image_tlv_sha256 + ) + else: + async for offset in smp_client.upload(image): + progress.update(offset / upload_size) finally: progress.done() _LOGGER.info("Mark image for testing") - r = await smp_client.request(ImageStatesWrite(hash=image_tlv_sha256), 1.0) - + r = await _request_with_retry( + smp_client, + partial(ImageStatesWrite, hash=image_tlv_sha256), + write_timeout, + retries, + target, + ) if error(r): raise EsphomeError(f"Failed to mark image for testing on {device}: {r}") await asyncio.sleep(RESET_DELAY) _LOGGER.info("Reset") - r = await smp_client.request(ResetWrite(), 1.0) + if udp: + await _udp_reset(smp_client, device, image_tlv_sha256) + return + r = await smp_client.request(ResetWrite(), write_timeout) if error(r): raise EsphomeError(f"Failed to reset {device}: {r}") diff --git a/esphome/components/nrf52/ota_udp.py b/esphome/components/nrf52/ota_udp.py new file mode 100644 index 00000000000..f164eb88567 --- /dev/null +++ b/esphome/components/nrf52/ota_udp.py @@ -0,0 +1,250 @@ +"""SMP over UDP for nRF52 OTA: the lossy-link pieces of the MCUmgr upload.""" + +import asyncio +from collections.abc import Iterator +from contextlib import contextmanager +from functools import partial +from hashlib import sha256 +import logging + +from smp import header as smpheader +from smpclient import SMPClient +from smpclient.generics import error, success +from smpclient.requests.image_management import ImageStatesRead, ImageUploadWrite +from smpclient.requests.os_management import ResetWrite +from smpclient.transport.udp import SMPUDPTransport + +from esphome.core import EsphomeError +from esphome.espota2 import ProgressBar + +from .ota import _find_image, _request_with_retry, _try_request + +# Thread is lossy and each chunk waits on a flash write synced with the radio +SMP_UDP_REQUEST_TIMEOUT = 8.0 +SMP_UDP_UPLOAD_RETRIES = 15 +SMP_UDP_STATE_RETRIES = 3 +SMP_UDP_PROBE_TIMEOUT = 2.0 +# MCUboot moves the whole image during the swap, then the device rejoins the network; +# 50 to 61 s on an nRF52840 over Thread, so leave room +SMP_UDP_REBOOT_WAIT = 120.0 +# After a lost reset reply: two probes tell an old image still running from a reboot +SMP_UDP_LOST_RESET_WAIT = 2 * SMP_UDP_PROBE_TIMEOUT +SMP_UDP_RESET_ATTEMPTS = 2 # the reset and at most one resend + +_LOGGER = logging.getLogger(__name__) + + +class SMPUnreachableError(EsphomeError): + """The device did not answer over UDP before anything was sent to it.""" + + +class _SequenceMatchingUDPTransport(SMPUDPTransport): + """Skips late replies to earlier attempts, which stay queued after a timeout.""" + + async def send_and_receive(self, data: bytes) -> bytes: + await self.send(data) + sequence = smpheader.Header.loads(data[: smpheader.Header.SIZE]).sequence + while True: + frame = await self.receive() + if ( + smpheader.Header.loads(frame[: smpheader.Header.SIZE]).sequence + == sequence + ): + return frame + _LOGGER.debug("Skipping a late reply to an earlier request") + + +def _drop_timeout_errors(record: logging.LogRecord) -> bool: + """Drop smpclient's ERROR record, payload included, for each timed-out request.""" + return not ( + record.levelno == logging.ERROR and record.getMessage().startswith("Timeout (") + ) + + +@contextmanager +def _quiet_smpclient_timeouts(enabled: bool) -> Iterator[None]: + if not enabled: + yield + return + smpclient_logger = logging.getLogger("smpclient") + smpclient_logger.addFilter(_drop_timeout_errors) + try: + yield + finally: + smpclient_logger.removeFilter(_drop_timeout_errors) + + +async def _image_in_slot(smp_client: SMPClient, image_hash: bytes, target: str) -> bool: + state = await _request_with_retry( + smp_client, ImageStatesRead, None, SMP_UDP_STATE_RETRIES, target + ) + if not success(state): + _LOGGER.warning( + "Could not read the image states, sending the image again: %s", state + ) + return False + if _find_image(state, image_hash) is not None: + return True + _LOGGER.warning("The device does not list the image, sending it again") + return False + + +async def _udp_upload( + smp_client: SMPClient, + image: bytes, + progress: ProgressBar, + target: str, + image_hash: bytes, +) -> None: + """Upload an image, resending each chunk until the device answers. + + smpclient's upload() gives up on the first lost reply. mcumgr ignores a write at + an unexpected offset and replies with the offset it wants, so resending is safe. + """ + total = len(image) + first = {"len": total, "image": 0, "sha": sha256(image).digest()} + off = 0 + highest = 0 + stalls = 0 + while off < total: + fields = first if off == 0 else {} + _, size = smp_client.get_max_cbor_and_data_size( + ImageUploadWrite(off=off, data=b"", **fields) + ) + make_request = partial( + ImageUploadWrite, off=off, data=image[off : off + size], **fields + ) + try: + response = await _request_with_retry( + smp_client, + make_request, + SMP_UDP_REQUEST_TIMEOUT, + SMP_UDP_UPLOAD_RETRIES, + target, + ) + except EsphomeError as exc: + raise EsphomeError(f"Upload stalled at offset {off}: {exc}") from exc + if error(response): + raise EsphomeError(f"Upload failed at offset {off}: {response}") + if response.off is None: + raise EsphomeError(f"No offset received at {off}: {response}") + if response.off > total: + raise EsphomeError(f"Device reported offset {response.off} past {total}") + # mcumgr clears its upload state after the last write, so a resent final chunk + # whose first reply was lost comes back with offset 0 + if ( + response.off == 0 + and off + size >= total + and await _image_in_slot(smp_client, image_hash, target) + ): + _LOGGER.info( + "The reply to the last chunk was lost; the device lists the image" + ) + progress.update(1) + return + # Follow the device's offset, so an upload it restarted is sent again. Only a + # new high resets the count, so stalls and repeated restarts both give up. + if response.off > highest: + highest = response.off + stalls = 0 + elif response.off <= off: + stalls += 1 + if stalls > SMP_UDP_UPLOAD_RETRIES: + raise EsphomeError(f"Upload not advancing past offset {highest}") + off = response.off + progress.update(off / total) + # match needs CONFIG_IMG_ENABLE_IMAGE_CHECK, which NCS cannot build with its PSA crypto. + # Marking the image only finds the slot by its header hash; MCUboot checks the whole + # image before it boots it. + if response.match is False: + raise EsphomeError("Upload failed: device reported mismatched SHA256") + if response.match is None: + _LOGGER.info( + "The device did not verify the image hash; MCUboot checks it at boot" + ) + + +async def _poll_for_new_image( + smp_client: SMPClient, image_hash: bytes, wait_s: float +) -> tuple[object | None, bool]: + """Poll the image states after a reset. + + Returns the last reply (None if there was none) and whether the device went + silent after it. Stops at the new image, at the first good reply after a + silence (the device rebooted), or after wait_s. + """ + # Also skips a reply the old image sends before it reboots + await asyncio.sleep(SMP_UDP_PROBE_TIMEOUT) + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + state = None + rebooted = silent = False + while loop.time() < deadline: + reply = await _try_request(smp_client, ImageStatesRead(), SMP_UDP_PROBE_TIMEOUT) + if reply is None: + rebooted = silent = True + continue + state = reply + silent = False + if success(reply) and ( + rebooted or getattr(_find_image(reply, image_hash), "active", False) + ): + break + return state, silent + + +async def _udp_reset(smp_client: SMPClient, device: str, image_hash: bytes) -> None: + """Reset the device and check that it comes back running the new image. + + Resends only when the old image still runs: a blind resend could reboot the new + image before it confirms itself, and MCUboot would roll the update back. MCUboot + boots the old image again when it cannot swap the new one in, and the new image + then stays pending, so every later upload fails until the slot is cleared. + """ + for attempt in range(SMP_UDP_RESET_ATTEMPTS): + reply = await _try_request(smp_client, ResetWrite(), SMP_UDP_REQUEST_TIMEOUT) + if reply is not None and error(reply): + raise EsphomeError(f"Failed to reset {device}: {reply}") + rebooting = reply is not None + state, silent = await _poll_for_new_image( + smp_client, + image_hash, + SMP_UDP_REBOOT_WAIT if rebooting else SMP_UDP_LOST_RESET_WAIT, + ) + if silent and not rebooting: + # Quiet after a reset with no reply, even after an earlier answer from the + # old image: it took effect, so wait for the reboot instead of resending + rebooting = True + state, silent = await _poll_for_new_image( + smp_client, image_hash, SMP_UDP_REBOOT_WAIT + ) + if state is None or silent: + # Silence after a reset most likely means it is still swapping or rejoining + _LOGGER.warning( + "%s did not answer after the reset; check that it runs the new image", + device, + ) + return + if not success(state): + raise EsphomeError( + f"Could not read the image states of {device} after the reset, it " + f"answered with an error: {state}" + ) + ours = _find_image(state, image_hash) + if ours is not None and ours.active: + _LOGGER.info("%s runs the new image", device) + return + if rebooting: + raise EsphomeError( + f"{device} rebooted but did not swap in the new image; it may be too " + f"large for the MCUboot slot: {state}" + ) + if ours is None or not ours.pending: + raise EsphomeError(f"{device} no longer has the new image pending: {state}") + if attempt == SMP_UDP_RESET_ATTEMPTS - 1: + raise EsphomeError( + f"{device} still runs the old image with the new one pending; it did not " + "answer the reset. Restart the device to apply the update." + ) + # Resetting the old image is harmless, even if it rebooted and could not swap + _LOGGER.info("%s still runs the old image, resending the reset", device) diff --git a/esphome/components/output/button/__init__.py b/esphome/components/output/button/__init__.py index 95ad18a56b9..32f654f31ba 100644 --- a/esphome/components/output/button/__init__.py +++ b/esphome/components/output/button/__init__.py @@ -1,29 +1,23 @@ +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import button, output import esphome.config_validation as cv -from esphome.const import CONF_DURATION, CONF_ID, CONF_OUTPUT +from esphome.const import CONF_DURATION, CONF_OUTPUT +from esphome.types import ConfigType -from .. import output_ns - -OutputButton = output_ns.class_("OutputButton", button.Button, cg.Component) - -CONFIG_SCHEMA = ( - button.button_schema(OutputButton) - .extend( - { - cv.Required(CONF_OUTPUT): cv.use_id(output.BinaryOutput), - cv.Required(CONF_DURATION): cv.positive_time_period_milliseconds, - } - ) - .extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = button.button_schema(button.ApplyButton).extend( + { + cv.Required(CONF_OUTPUT): cv.use_id(output.BinaryOutput), + cv.Required(CONF_DURATION): cv.positive_time_period_milliseconds, + } ) -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_duration(config[CONF_DURATION])) - - await cg.add_variable(var.set_output, config[CONF_OUTPUT]) - - await cg.register_component(var, config) - await button.register_button(var, config) +async def to_code(config: ConfigType) -> None: + out = parent_ref(await cg.get_variable(config[CONF_OUTPUT])) + duration = config[CONF_DURATION].total_milliseconds + await button.sub_buttons(config).create( + config, + None, + apply=f"output::press_output_button(entity, {out}, {duration})", + ) diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 17ffc39d7f0..3e7a4ba8c2d 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -1,21 +1,12 @@ #include "output_button.h" -#include "esphome/core/log.h" +#include "esphome/core/application.h" namespace esphome::output { -ESPHOME_LOG_TAG(TAG, "output.button"); - -static constexpr uint32_t RESET_TIMEOUT_ID = 0; - -void OutputButton::dump_config() { - LOG_BUTTON("", "Output Button", this); - ESP_LOGCONFIG(TAG, " Duration: %.1fs", this->duration_ / 1e3f); -} -void OutputButton::press_action() { - this->output_->turn_on(); - - // One timeout id, so a second press restarts the reset instead of stacking another - this->set_timeout(RESET_TIMEOUT_ID, this->duration_, [this]() { this->output_->turn_off(); }); +void press_output_button(button::Button *button, BinaryOutput *output, uint32_t duration) { + output->turn_on(); + // Keyed on the button, so a second press replaces the pending reset instead of stacking another + App.scheduler.set_timeout(button, duration, [output]() { output->turn_off(); }); } } // namespace esphome::output diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index bf6be8afe10..d516202abf4 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -1,23 +1,13 @@ #pragma once -#include "esphome/core/component.h" +#include + #include "esphome/components/button/button.h" #include "esphome/components/output/binary_output.h" namespace esphome::output { -class OutputButton final : public button::Button, public Component { - public: - void dump_config() override; - - void set_output(BinaryOutput *output) { output_ = output; } - void set_duration(uint32_t duration) { duration_ = duration; } - - protected: - void press_action() override; - - output::BinaryOutput *output_; - uint32_t duration_; -}; +/// Turn the output on and off again after duration ms; a second press restarts the wait. +void press_output_button(button::Button *button, BinaryOutput *output, uint32_t duration); } // namespace esphome::output diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp index 469700e01ed..407ba09fd6f 100644 --- a/esphome/components/pn71xx/pn71xx.cpp +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -1071,7 +1071,7 @@ void PN71xx::card_emu_t4t_get_response_(const std::span response, ok = true; } else if (apdu_starts_with(CARD_EMU_T4T_READ, sizeof(CARD_EMU_T4T_READ)) && apdu_size == 5) { // READ BINARY: CLA INS P1 P2 Le, where P1-P2 is the offset - const uint16_t offset = (apdu_begin[2] << 8) | apdu_begin[3]; + const uint16_t offset = encode_uint16(apdu_begin[2], apdu_begin[3]); const uint8_t length = apdu_begin[4]; if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index dc998f00864..d7769c95080 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -45,7 +45,7 @@ void PZEM004T::loop() { break; } case 0xA0: { // Voltage Response - uint16_t int_voltage = (uint16_t(resp[1]) << 8) | (uint16_t(resp[2]) << 0); + uint16_t int_voltage = encode_uint16(resp[1], resp[2]); float voltage = int_voltage + (resp[3] / 10.0f); PUBLISH_STATE(this->voltage_sensor_, voltage); ESP_LOGD(TAG, "Got Voltage %.1f V", voltage); @@ -53,7 +53,7 @@ void PZEM004T::loop() { break; } case 0xA1: { // Current Response - uint16_t int_current = (uint16_t(resp[1]) << 8) | (uint16_t(resp[2]) << 0); + uint16_t int_current = encode_uint16(resp[1], resp[2]); float current = int_current + (resp[3] / 100.0f); PUBLISH_STATE(this->current_sensor_, current); ESP_LOGD(TAG, "Got Current %.2f A", current); @@ -61,7 +61,7 @@ void PZEM004T::loop() { break; } case 0xA2: { // Active Power Response - uint16_t power = (uint16_t(resp[1]) << 8) | (uint16_t(resp[2]) << 0); + uint16_t power = encode_uint16(resp[1], resp[2]); PUBLISH_STATE(this->power_sensor_, power); ESP_LOGD(TAG, "Got Power %u W", power); this->write_state_(READ_ENERGY); @@ -103,9 +103,7 @@ void PZEM004T::write_state_(PZEM004T::PZEM004TReadState state) { data[3] = 1; data[4] = 1; data[5] = 0; - data[6] = 0; - for (int i = 0; i < 6; i++) - data[6] += data[i]; + data[6] = sum_bytes(data.data(), 6); this->write_array(data); this->read_state_ = state; diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 431b44db3ed..66110301255 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -33,7 +33,7 @@ static constexpr int16_t SPEED_SENTINEL_256 = 256; // Decode coordinate/speed value from RD-03D format // Per datasheet: MSB=1 means positive, MSB=0 means negative static constexpr int16_t decode_value(uint8_t low_byte, uint8_t high_byte) { - int16_t value = ((high_byte & 0x7F) << 8) | low_byte; + int16_t value = encode_uint16(high_byte & 0x7F, low_byte); if ((high_byte & 0x80) == 0) { value = -value; } @@ -150,7 +150,7 @@ void RD03DComponent::process_frame_() { int16_t x = decode_value(x_low, x_high); int16_t y = decode_value(y_low, y_high); int16_t speed = decode_value(speed_low, speed_high); - uint16_t resolution = (res_high << 8) | res_low; + uint16_t resolution = encode_uint16(res_high, res_low); // Check if target is present // Requires non-zero coordinates AND valid speed (not a sentinel value) diff --git a/esphome/components/remote_base/pd_pioneer_protocol.h b/esphome/components/remote_base/pd_pioneer_protocol.h index b324218f14b..fc713ef9015 100644 --- a/esphome/components/remote_base/pd_pioneer_protocol.h +++ b/esphome/components/remote_base/pd_pioneer_protocol.h @@ -50,12 +50,7 @@ class PDPioneerData { const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } protected: - uint8_t checksum_() const { - uint8_t cs = this->is_odd_burst() ? 15 : 0; - for (uint8_t idx = 0; idx < OFFSET_CS; idx++) - cs += this->data_[idx]; - return cs; - } + uint8_t checksum_() const { return sum_bytes(this->data_.data(), OFFSET_CS, this->is_odd_burst() ? 15 : 0); } std::array data_{}; }; diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 1fe59153e69..1e5ce5c6c10 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -31,14 +31,7 @@ void SenseAirComponent::update() { response[8], response[9], response[10], response[11], response[12]); this->status_set_warning(); - while (this->available()) { - uint8_t b; - if (this->read_byte(&b)) { - ESP_LOGV(TAG, " ... %02x", b); - } else { - ESP_LOGV(TAG, " ... nothing read"); - } - } + this->discard_available(); return; } @@ -121,7 +114,7 @@ void SenseAirComponent::abc_get_period() { return; } - const uint16_t hours = (uint16_t(response[3]) << 8) | response[4]; + const uint16_t hours = encode_uint16(response[3], response[4]); ESP_LOGD(TAG, "SenseAir Read ABC Period: %u hours", hours); } diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 20288fa88e0..80e15833b28 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -8,6 +8,7 @@ #include "esphome/components/sensor/filter.h" #endif +#include #include #include @@ -165,4 +166,20 @@ class Sensor : public EntityBase { } sensor_flags_{}; }; +/** Read another sensor as an input, such as the temperature a gas sensor compensates for. + * + * @return The state of `sensor` when it lies in [min, max] (both included); NAN when `sensor` is null, its + * state is NAN (as it is before the first publish) or its state is out of range. + * The caller decides whether to skip or fall back, and what to log. + */ +inline float source_value(const Sensor *sensor, float min, float max) { + if (sensor == nullptr) + return NAN; + const float value = sensor->state; + // isnan first: callers test the result with isnan, and the compiler can reuse this check + if (std::isnan(value) || value < min || value > max) + return NAN; + return value; +} + } // namespace esphome::sensor diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index 6e15418e38a..d19f97a8708 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -339,7 +339,7 @@ int ShellyDimmer::handle_byte_(uint8_t c) { if (pos == 4 + payload_len + 1) { // Verify checksum. - const uint16_t csum = (this->buffer_[pos - 1] << 8 | c); + const uint16_t csum = encode_uint16(this->buffer_[pos - 1], c); const uint16_t csum_verify = shelly_dimmer_checksum(&this->buffer_[1], 3 + payload_len); if (csum != csum_verify) { return -1; diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index c104316d475..2fe3039f16c 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -58,7 +58,8 @@ LAT_LON_REGEX = re.compile( r"(?:([0-9]+)\s*°)?\s*" r"(?:([0-9]+)\s*[′\'])?\s*" r'(?:([0-9]+)\s*[″"])?\s*' - r"([NESW])?" + r"([NESW])?", + re.IGNORECASE, ) @@ -72,7 +73,7 @@ def parse_latlon(value: Any) -> float: pass value = cv.string_strict(value) - m = LAT_LON_REGEX.match(value) + m = LAT_LON_REGEX.fullmatch(value.strip()) if m is None: raise cv.Invalid("Invalid format for latitude/longitude") @@ -82,10 +83,15 @@ def parse_latlon(value: Any) -> float: second = m.group(4) d = m.group(5) + if deg is None and minute is None and second is None: + raise cv.Invalid("Invalid format for latitude/longitude") + if sign == "-" and d: + raise cv.Invalid("Use either a minus sign or a hemisphere letter, not both") + val = float(deg or 0) + float(minute or 0) / 60 + float(second or 0) / 3600 if sign == "-": val *= -1 - if d and d in "SW": + if d and d.upper() in "SW": val *= -1 return val diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 400de8021cb..474f560251e 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -76,12 +76,12 @@ void Syslog::on_log(uint8_t level, const char *tag, const char *message, size_t this->log_(level, tag, message, message_len); } -void Syslog::log_(const int level, const char *tag, const char *message, size_t message_len) const { +void Syslog::log_(const uint8_t level, const char *tag, const char *message, size_t message_len) const { if (level > this->log_level_) return; // Syslog PRI calculation: facility * 8 + severity int severity = 7; - if ((unsigned) level <= 7) { + if (level <= 7) { severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index b1482683b5a..2e1883a7626 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -18,19 +18,20 @@ size_t append_redacted_message(char *buf, size_t size, size_t pos, const char *m class Syslog final : public Component, public Parented { public: - Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} + Syslog(uint8_t level, time::RealTimeClock *time) : time_(time), log_level_(level) {} void setup() override; void on_log(uint8_t level, const char *tag, const char *message, size_t message_len); void set_strip(bool strip) { this->strip_ = strip; } - void set_facility(int facility) { this->facility_ = facility; } + void set_facility(uint8_t facility) { this->facility_ = facility; } void set_format(SyslogFormat format) { this->format_ = format; } protected: - int log_level_; - void log_(int level, const char *tag, const char *message, size_t message_len) const; + void log_(uint8_t level, const char *tag, const char *message, size_t message_len) const; + time::RealTimeClock *time_; + uint8_t log_level_; + uint8_t facility_{16}; bool strip_{true}; - int facility_{16}; SyslogFormat format_{SYSLOG_FORMAT_RFC3164}; }; } // namespace esphome::syslog diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index d358b31cce1..e5e40db543d 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/helpers.h" namespace esphome::tcs34725 { @@ -57,7 +58,7 @@ class TCS34725Component final : public PollingComponent, public i2c::I2CDevice { uint8_t buffer[2]; auto retval = this->read_register(a_register, buffer, 2); if (retval == i2c::ERROR_OK) - data = (uint16_t(buffer[1]) << 8) | (uint16_t(buffer[0]) & 0xFF); + data = encode_uint16(buffer[1], buffer[0]); return retval; } i2c::ErrorCode write_config_register_(uint8_t a_register, uint8_t data) { diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index 74906ba5c45..4cb50af62ba 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -11,11 +11,12 @@ static constexpr size_t TEE501_SERIAL_NUMBER_SIZE = 7; void TEE501Component::setup() { uint8_t address[] = {0x70, 0x29}; uint8_t identification[9]; - this->read(identification, 9); - this->write_read(address, sizeof address, identification, sizeof identification); + if (this->write_read(address, sizeof address, identification, sizeof identification) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { - this->error_code_ = CRC_CHECK_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("CRC check failed")); return; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -27,38 +28,27 @@ void TEE501Component::setup() { void TEE501Component::dump_config() { ESP_LOGCONFIG(TAG, "TEE501:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case CRC_CHECK_FAILED: - ESP_LOGE(TAG, "The crc check failed"); - break; - case NONE: - default: - break; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "TEE501", this); } void TEE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2); + if (this->write(address_1, 2) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } this->set_timeout(50, [this]() { uint8_t i2c_response[3]; - this->read(i2c_response, 3); - if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true)) { - this->error_code_ = CRC_CHECK_FAILED; - this->status_set_warning(); + if (this->read(i2c_response, 3) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - float temperature = (float) encode_uint16(i2c_response[0], i2c_response[1]); - if (temperature > 55536) { - temperature = (temperature - 65536) / 100; - } else { - temperature = temperature / 100; + if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true)) { + this->status_set_warning(LOG_STR("CRC check failed")); + return; } + float temperature = static_cast(encode_uint16(i2c_response[0], i2c_response[1])) / 100.0f; ESP_LOGD(TAG, "Got temperature=%.2f°C", temperature); this->publish_state(temperature); this->status_clear_warning(); diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index bbd63a4e2b8..f28ff6e70d1 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -12,9 +12,6 @@ class TEE501Component final : public sensor::Sensor, public PollingComponent, pu void setup() override; void dump_config() override; void update() override; - - protected: - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; } // namespace esphome::tee501 diff --git a/esphome/components/tem3200/tem3200.cpp b/esphome/components/tem3200/tem3200.cpp index 2552bed072b..9f57b6c77a2 100644 --- a/esphome/components/tem3200/tem3200.cpp +++ b/esphome/components/tem3200/tem3200.cpp @@ -85,10 +85,10 @@ i2c::ErrorCode TEM3200Component::read_(uint8_t &status, uint16_t &raw_temperatur } // extract top 6 bits of first byte and all bits of second byte for pressure - raw_pressure = (((response[0] & 0x3f)) << 8 | response[1]); + raw_pressure = encode_uint16(response[0] & 0x3f, response[1]); // extract all bytes of 3rd byte and top 3 bits of fourth byte for temperature - raw_temperature = ((response[2] << 3) | (response[3] & 0xe0) >> 5); + raw_temperature = encode_uint16(response[2], response[3]) >> 5; return i2c::ERROR_OK; } diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 2ad3cd9c314..7221bb92a61 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -169,6 +169,14 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): raise cv.Invalid( f"Repeat for '/' time expression must be an integer, got {repeat}" ) from None + if not min_value <= offset_n <= max_value: + raise cv.Invalid( + f"Step '{part}': offset {offset_n} is out of range (min={min_value} max={max_value})" + ) + if repeat_n < 1: + raise cv.Invalid( + f"Step '{part}': repeat must be at least 1, got {repeat_n}" + ) return set(range(offset_n, max_value + 1, repeat_n)) if "-" in part: data = part.split("-") @@ -183,8 +191,13 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): end_n = _parse_cron_int( end, special_mapping, "Number for time range must be integer, got {}" ) + for n in (begin_n, end_n): + if not min_value <= n <= max_value: + raise cv.Invalid( + f"Range '{part}': {n} is out of range (min={min_value} max={max_value})" + ) if end_n < begin_n: - return set(range(end_n, max_value + 1)) | set(range(min_value, begin_n + 1)) + return set(range(begin_n, max_value + 1)) | set(range(min_value, end_n + 1)) return set(range(begin_n, end_n + 1)) return { diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 76a061cadf7..f2d3e12e372 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -1,6 +1,7 @@ #include "tof10120_sensor.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include // Very basic support for TOF10120 distance sensor @@ -41,7 +42,7 @@ void TOF10120Sensor::update() { return; } - uint32_t distance_mm = (data[0] << 8) | data[1]; + uint32_t distance_mm = encode_uint16(data[0], data[1]); ESP_LOGI(TAG, "Data read: %" PRIu32 "mm", distance_mm); if (distance_mm == TOF10120_OUT_OF_RANGE_VALUE) { diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 516a4300d75..030914f9a70 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -610,7 +610,7 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[3] = ~message[2]; // Byte 4u: Temp if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - temperature = (temperature * 1.8f) + 32; + temperature = celsius_to_fahrenheit(temperature); temp_adjd = temperature - TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN; } diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 01bee9587cc..bb167dd481e 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -1,6 +1,7 @@ #include "tsl2591.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::tsl2591 { @@ -250,7 +251,7 @@ uint32_t TSL2591Component::get_combined_illuminance() { ESP_LOGE(TAG, "I2C read failed"); return 0; } - ch0_16 = (ch0high << 8) | ch0low; + ch0_16 = encode_uint16(ch0high, ch0low); if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_CHAN1_LOW, &ch1low)) { ESP_LOGE(TAG, "I2C read failed"); return 0; @@ -259,7 +260,7 @@ uint32_t TSL2591Component::get_combined_illuminance() { ESP_LOGE(TAG, "I2C read failed"); return 0; } - ch1_16 = (ch1high << 8) | ch1low; + ch1_16 = encode_uint16(ch1high, ch1low); x32 = (ch1_16 << 16) | ch0_16; this->disable_if_power_saving_(); diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 79a3fab9c12..b21c7169210 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -133,7 +133,7 @@ bool Tuya::validate_message_() { return true; } - uint16_t length = (uint16_t(data[4]) << 8) | (uint16_t(data[5])); + uint16_t length = encode_uint16(data[4], data[5]); // wait until all data is read if (at - 6 < length) @@ -381,7 +381,7 @@ void Tuya::handle_datapoints_(const uint8_t *buffer, size_t len) { datapoint.type = (TuyaDatapointType) buffer[1]; datapoint.value_uint = 0; - size_t data_size = (buffer[2] << 8) + buffer[3]; + size_t data_size = encode_uint16(buffer[2], buffer[3]); const uint8_t *data = buffer + 4; size_t data_len = len - 4; if (data_size > data_len) { diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 821d8264dc4..4fc285b57f2 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,34 +1,29 @@ from esphome import automation -import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv -from esphome.const import CONF_DATA +from esphome.const import CONF_DATA, CONF_UART_ID +from esphome.types import ConfigType -from .. import uart_ns, validate_raw_payload +from .. import validate_raw_payload CODEOWNERS = ["@ssieb"] DEPENDENCIES = ["uart"] -UARTButton = uart_ns.class_("UARTButton", button.Button, uart.UARTDevice, cg.Component) - - CONFIG_SCHEMA = ( - button.button_schema(UARTButton) + button.button_schema(button.ApplyButton) .extend( { cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) ) -async def to_code(config): - var = await button.new_button(config) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - +async def to_code(config: ConfigType) -> None: data = config[CONF_DATA] - cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) + table = automation.progmem_bytes("uart_data", data) + await button.sub_buttons(config, parent=config[CONF_UART_ID]).create( + config, None, apply=f"write_array_progmem({table}, {len(data)})" + ) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp deleted file mode 100644 index 07cc75bfdf8..00000000000 --- a/esphome/components/uart/button/uart_button.cpp +++ /dev/null @@ -1,15 +0,0 @@ -#include "uart_button.h" -#include "esphome/core/log.h" - -namespace esphome::uart { - -ESPHOME_LOG_TAG(TAG, "uart.button"); - -void UARTButton::press_action() { - ESP_LOGD(TAG, "'%s': Sending data", LOG_STR_ARG(this->get_log_name())); - this->write_array_progmem(this->data_, this->data_len_); -} - -void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } - -} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h deleted file mode 100644 index 903f945fd30..00000000000 --- a/esphome/components/uart/button/uart_button.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/components/uart/uart.h" -#include "esphome/components/button/button.h" - -namespace esphome::uart { - -class UARTButton final : public button::Button, public UARTDevice, public Component { - public: - void set_data(const uint8_t *data, uint16_t len) { - this->data_ = data; - this->data_len_ = len; - } - - void dump_config() override; - - protected: - void press_action() override; - const uint8_t *data_{nullptr}; - uint16_t data_len_{0}; -}; - -} // namespace esphome::uart diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index d9dda5e1486..d189674d6c9 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_TEMPERATURE_SENSOR import esphome.config_validation as cv from esphome.const import ( CONF_EC, @@ -19,7 +20,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CONF_SOLUTION = "solution" -CONF_TEMPERATURE_SENSOR = "temperature_sensor" CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" ufire_ec_ns = cg.esphome_ns.namespace("ufire_ec") diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 06f9021e819..efe7d837001 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_TEMPERATURE_SENSOR import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -18,7 +19,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CONF_SOLUTION = "solution" -CONF_TEMPERATURE_SENSOR = "temperature_sensor" ufire_ise_ns = cg.esphome_ns.namespace("ufire_ise") UFireISEComponent = ufire_ise_ns.class_( diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index b95a3894f32..f634b369425 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -103,10 +103,7 @@ static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) - sum += data[i]; - return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum_bytes(data + 1, SOFTWARE_VERSION_CHECKSUM_INDEX - 1); } static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { @@ -137,10 +134,7 @@ static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) - sum += data[i]; - return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum_bytes(data, PASSIVE_WITH_ID_CHECKSUM_INDEX); } #endif diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e2f1c9e0732..a8a261aaec4 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -2,6 +2,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/components/bytebuffer/bytebuffer.h" @@ -172,7 +173,7 @@ bool USBUartTypeCP210X::config_device_step(uint8_t step, bool ok, const uint8_t case 2: { if (!ok) return false; - const uint32_t version = (response[0] << 16) | (response[1] << 8) | response[2]; + const uint32_t version = encode_uint24(response[0], response[1], response[2]); ESP_LOGD(TAG, "CP2102N firmware %u.%u.%u", response[0], response[1], response[2]); this->no_flow_control_ = version <= CP2102N_LAST_FW_WITHOUT_FLOW_CONTROL; if (this->no_flow_control_) { diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 3f50f8e6876..714774ea18d 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -217,7 +217,8 @@ optional parse_xiaomi_header(const uint8_t *raw, size_t size) } else if (device_uuid == 0x045b) { // rectangular body, e-ink display result.type = XiaomiParseResult::TYPE_LYWSD02; result.name = "LYWSD02"; - } else if (device_uuid == 0x2542) { // rectangular body, e-ink display — with bindkeys + } else if (device_uuid == 0x2542 || // rectangular body, e-ink display — with bindkeys + device_uuid == 0x16e4) { // same, hardware revision F3_A1 (firmware 2.0.1_0060) result.type = XiaomiParseResult::TYPE_LYWSD02MMC; result.name = "LYWSD02MMC"; if (size == 19) diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index df68f574a09..4d67a2dfe73 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -14,12 +14,9 @@ void XiaomiCGD1::dump_config() { } void XiaomiCGD1::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_cgd1 diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index 324e42ab4a9..70030ee9635 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -14,12 +14,9 @@ void XiaomiCGDK2::dump_config() { } void XiaomiCGDK2::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_cgdk2 diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index dbc3099c0af..bdc9354f571 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -19,12 +19,9 @@ void XiaomiCGG1::dump_config() { } void XiaomiCGG1::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_cgg1 diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index c22bbf192c5..30a183f1b2a 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -13,12 +13,9 @@ void XiaomiLYWSD02::dump_config() { } void XiaomiLYWSD02::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_lywsd02 diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index d56702ee0dd..2df58ab7f7c 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -14,12 +14,9 @@ void XiaomiLYWSD02MMC::dump_config() { } void XiaomiLYWSD02MMC::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_lywsd02mmc diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index f7cb4d4f945..7e2dfc2a11c 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -16,13 +16,11 @@ void XiaomiLYWSD03MMC::dump_config() { } void XiaomiLYWSD03MMC::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); + publish_optional(this->temperature_sensor_, res.temperature); // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 if (res.humidity.has_value() && this->humidity_sensor_ != nullptr) this->humidity_sensor_->publish_state(truncf(*res.humidity)); - 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); } } // namespace esphome::xiaomi_lywsd03mmc diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 67956e54ff5..a631e57bde3 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -13,12 +13,9 @@ void XiaomiLYWSDCGQ::dump_config() { } void XiaomiLYWSDCGQ::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_lywsdcgq diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 83f87ca7e3b..624dfa6d78d 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -13,12 +13,9 @@ void XiaomiMHOC303::dump_config() { } void XiaomiMHOC303::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.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->battery_level_sensor_, res.battery_level); } } // namespace esphome::xiaomi_mhoc303 diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index be28f693b9e..0a5b96b469c 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -16,13 +16,11 @@ void XiaomiMHOC401::dump_config() { } void XiaomiMHOC401::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); + publish_optional(this->temperature_sensor_, res.temperature); // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 if (res.humidity.has_value() && this->humidity_sensor_ != nullptr) this->humidity_sensor_->publish_state(truncf(*res.humidity)); - 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); } } // namespace esphome::xiaomi_mhoc401 diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index ee7887dd709..082b88cbd48 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -16,13 +16,11 @@ void XiaomiXMWSDJ04MMC::dump_config() { } void XiaomiXMWSDJ04MMC::publish_result(const xiaomi_ble::XiaomiParseResult &res) { - if (res.temperature.has_value() && this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(*res.temperature); + publish_optional(this->temperature_sensor_, res.temperature); // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 if (res.humidity.has_value() && this->humidity_sensor_ != nullptr) this->humidity_sensor_->publish_state(truncf(*res.humidity)); - 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); } } // namespace esphome::xiaomi_xmwsdj04mmc diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index 903f8d6859a..38bd9855144 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -110,7 +110,7 @@ int16_t XPT2046Component::read_adc_(uint8_t ctrl) { this->transfer_array(data, sizeof(data)); - return ((data[1] << 8) | data[2]) >> 3; + return encode_uint16(data[1], data[2]) >> 3; } } // namespace esphome::xpt2046 diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index b6ef932e4f8..15bc3c58814 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -2,7 +2,16 @@ import logging from esphome import pins import esphome.codegen as cg +from esphome.components import network from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS +from esphome.components.nrf52.const import ( + CONF_TRANSPORT, + CONF_UDP, + FLASH_SECTOR_SIZE, + IPV4_UDP_HEADERS, + IPV6_UDP_HEADERS, + SMP_UDP_LINK_MTU, +) from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( @@ -46,18 +55,24 @@ ZephyrMcumgrOTAComponent = cg.esphome_ns.namespace("zephyr_mcumgr").class_( ) CONF_BLE = "ble" -CONF_TRANSPORT = "transport" def _validate_transport(conf: ConfigType) -> ConfigType: transport = conf[CONF_TRANSPORT] - if transport[CONF_BLE] or CONF_HARDWARE_UART in transport: + if transport[CONF_BLE] or transport[CONF_UDP] or CONF_HARDWARE_UART in transport: return conf raise cv.Invalid( - f"At least one transport protocol has to be enabled. Set '{CONF_BLE}: true' or '{CONF_HARDWARE_UART}'" + f"At least one transport protocol has to be enabled. " + f"Set '{CONF_BLE}: true', '{CONF_UDP}: true' or '{CONF_HARDWARE_UART}'" ) +def _require_ipv6_for_udp(conf: ConfigType) -> ConfigType: + if conf[CONF_TRANSPORT][CONF_UDP]: + network.require_ipv6("zephyr_mcumgr")(conf) + return conf + + UARTS = { "CDC": ("cdc_acm_uart0", 0), "CDC1": ("cdc_acm_uart1", 1), @@ -73,6 +88,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TRANSPORT, default={CONF_BLE: True}): cv.Schema( { cv.Optional(CONF_BLE, default=False): cv.boolean, + cv.Optional(CONF_UDP, default=False): cv.boolean, cv.Optional( CONF_HARDWARE_UART, ): cv.one_of(*UARTS, upper=True), @@ -88,6 +104,7 @@ CONFIG_SCHEMA = cv.All( .extend(BASE_OTA_SCHEMA) .extend(cv.COMPONENT_SCHEMA), _validate_transport, + _require_ipv6_for_udp, cv.only_with_framework(Framework.ZEPHYR), ) @@ -103,6 +120,13 @@ def _validate_ble_server(config: ConfigType) -> None: raise cv.Invalid(f"'{KEY_ZEPHYR_BLE_SERVER}' component is required for BLE OTA") +def _validate_udp_network(config: ConfigType) -> None: + if config[CONF_TRANSPORT][CONF_UDP] and "network" not in CORE.loaded_integrations: + raise cv.Invalid( + f"'{CONF_UDP}' OTA transport requires a network, for example 'openthread:'" + ) + + def _validate_bootloader(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader == BOOTLOADER_MCUBOOT: @@ -118,6 +142,7 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) + _validate_udp_network(config) _validate_bootloader(config) if "zephyr_coredump" in fv.full_config.get(): _LOGGER.warning( @@ -174,6 +199,18 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP + if transport[CONF_UDP]: + ipv4 = CORE.config[network.DOMAIN][network.CONF_ENABLE_IPV4] + zephyr_add_prj_conf("NET_SOCKETS", True) + zephyr_add_prj_conf("MCUMGR_TRANSPORT_UDP", True) + zephyr_add_prj_conf("MCUMGR_TRANSPORT_UDP_IPV6", True) + zephyr_add_prj_conf("MCUMGR_TRANSPORT_UDP_IPV4", ipv4) + # The largest frame the host sends; the default 1500 MTU and 2048 B buffers waste RAM + udp_mtu = SMP_UDP_LINK_MTU - (IPV4_UDP_HEADERS if ipv4 else IPV6_UDP_HEADERS) + zephyr_add_prj_conf("MCUMGR_TRANSPORT_UDP_MTU", udp_mtu) + if not transport[CONF_BLE]: + # BLE sets its own buffer size; UDP needs only room for one frame + zephyr_add_prj_conf("MCUMGR_TRANSPORT_NETBUF_SIZE", udp_mtu) if CONF_HARDWARE_UART in transport: zephyr_add_prj_conf("SERIAL", True) uart_name = UARTS[transport[CONF_HARDWARE_UART]][0] @@ -232,7 +269,7 @@ async def to_code(config: ConfigType) -> None: top = min(s.address for s in sections if s.address >= sd_end) slot0_start = sd_end + mcuboot_size # Align slot size down to a 4 KB sector boundary - slot_size = ((top - slot0_start) // 2 // 0x1000) * 0x1000 + slot_size = (top - slot0_start) // 2 // FLASH_SECTOR_SIZE * FLASH_SECTOR_SIZE slot1_start = slot0_start + slot_size def _mcuboot_partition_overlay() -> str: diff --git a/esphome/components/zyaura/zyaura.cpp b/esphome/components/zyaura/zyaura.cpp index 5f203b18939..667d7758242 100644 --- a/esphome/components/zyaura/zyaura.cpp +++ b/esphome/components/zyaura/zyaura.cpp @@ -1,5 +1,6 @@ #include "zyaura.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::zyaura { @@ -28,7 +29,7 @@ bool IRAM_ATTR ZaDataProcessor::decode(uint32_t ms, bool data) { } this->message->type = (ZaDataType) this->buffer_[ZA_BYTE_TYPE]; - this->message->value = this->buffer_[ZA_BYTE_HIGH] << 8 | this->buffer_[ZA_BYTE_LOW]; + this->message->value = encode_uint16(this->buffer_[ZA_BYTE_HIGH], this->buffer_[ZA_BYTE_LOW]); return true; } } diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a6f74f729f2..c1e066ebed0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1712,8 +1712,10 @@ def ssid(value): value = string_strict(value) if not value: raise Invalid("SSID can't be empty.") - if len(value) > 32: - raise Invalid("SSID can't be longer than 32 characters") + # 802.11 limits the SSID to 32 octets, and the C++ components compare the + # UTF-8 byte length against that limit, so measure bytes here as well + if (size := len(value.encode("utf-8"))) > 32: + raise Invalid(f"SSID can't be longer than 32 bytes (got {size} bytes in UTF-8)") return value @@ -1943,10 +1945,10 @@ def _parse_percentage(value: object) -> float: return float(value) -def percentage_int(value): - if isinstance(value, str) and value.endswith("%"): - value = int(value[:-1].rstrip()) - return value +def percentage_int(value: object) -> int: + if isinstance(value, str): + return int_(value.strip().removesuffix("%")) + return int_(value) def invalid(message): @@ -2158,7 +2160,7 @@ def dimensions(value): raise Invalid( "Dimensions must be a string (WIDTHxHEIGHT). Got a number instead, try quoting the value." ) - match = re.match(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) + match = re.fullmatch(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) if not match: raise Invalid( f"Invalid value '{value}' for dimensions. Only WIDTHxHEIGHT is allowed." @@ -2236,7 +2238,10 @@ def entity_id(value): value = string_strict(value).lower() if value.count(".") != 1: raise Invalid("Entity ID must have exactly one dot in it") - for x in value.split("."): + parts = value.split(".") + if not all(parts): + raise Invalid("Entity ID must be of the form '.'") + for x in parts: for c in x: if c not in ENTITY_ID_CHARACTERS: raise Invalid(f"Invalid character for entity ID: {c}") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a9bc9af0466..271753ab139 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,8 @@ #define GPS_LISTENER_COUNT 1 #define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2 #define I2C_PORT_FREQUENCY_COUNT 1 +#define LD600X_AREA_KINDS 3 +#define LD600X_MAX_TARGETS 3 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define USE_MDNS_EXTRA_SERVICES diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 6e166216ac7..5e385734a63 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -305,6 +305,12 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E esphome_publish_state_entity_->publish_state(__VA_ARGS__); \ } while (0) +/// Publish `value` to `entity` when the entity is configured and the value is set. +template inline void publish_optional(E *entity, const optional &value) { + if (entity != nullptr && value.has_value()) + entity->publish_state(*value); +} + /** Base class for entities that track a typed state value with change-detection and callbacks. * * This class does not store the state value — subclasses own their storage. Whether a state diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py index 7164831d969..e8a4ffb2faa 100644 --- a/esphome/platform_hooks.py +++ b/esphome/platform_hooks.py @@ -38,8 +38,11 @@ _LOGGER = logging.getLogger(__name__) # Hooks whose loss only degrades diagnostics or upload size; skipping one # of these is logged at debug, while skipping a hook that changes what the # CLI does (upload method, log transport) warns. A new hook is loud by -# default. -COSMETIC_HOOKS: Final = frozenset({"process_stacktrace", "ota_image"}) +# default. has_network_ota is asked several times per run, and losing it +# is already reported by the upload_program warning. +COSMETIC_HOOKS: Final = frozenset( + {"process_stacktrace", "ota_image", "has_network_ota"} +) # Platforms whose firmware prints the crash handler report share one gate. _CRASH_HANDLER_GATE = r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT" @@ -75,6 +78,8 @@ STACKTRACE_GATES: Final[dict[str, str]] = { PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = { "show_logs": frozenset({PLATFORM_NRF52}), "upload_program": frozenset({PLATFORM_NRF52}), + # Whether the config has a network OTA that upload_program handles + "has_network_ota": frozenset({PLATFORM_NRF52}), "process_stacktrace": frozenset(STACKTRACE_GATES), # The file an app update sends, when it is not the build output itself "ota_image": frozenset({PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX}), diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 36751074d96..0950f57af46 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -37,8 +37,14 @@ _OLD_GCC_PIO_PLATFORM = "libretiny" _MOVABLE_PCH_GCC = 12 # Relative: compiles run in the project root _CC1_DIR = "pch_cc1" +_CC1PLUS_START = '#!/usr/bin/env -S "{python}" -IS' +# macOS splits a shebang on whitespace, so such a Python path goes through +# sh; to Python the exec line is a string +_CC1PLUS_START_SH = """\ +#!/bin/sh +''''exec {python} -IS "$0" "$@" # '''""" _CC1PLUS_WRAPPER = """\ -#!/usr/bin/env -S "{python}" -IS +{start} # Written by ESPHome: starts cc1plus without address randomisation, so it # loads the precompiled header. See esphome/platformio/pch.py.script. import ctypes @@ -144,7 +150,12 @@ def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): wrapper = proj_dir / _CC1_DIR / "cc1plus" wrapper.parent.mkdir(exist_ok=True) real = os.path.normpath(real) - wrapper.write_text(_CC1PLUS_WRAPPER.format(python=sys.executable, real=real)) + python = sys.executable + if any(c.isspace() for c in python): + start = _CC1PLUS_START_SH.format(python=shlex.quote(python)) + else: + start = _CC1PLUS_START.format(python=python) + wrapper.write_text(_CC1PLUS_WRAPPER.format(start=start, real=real)) wrapper.chmod(0o755) return [f"-B{_CC1_DIR}/"] diff --git a/requirements.txt b/requirements.txt index d2984c3bd47..1d9d80f6b78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.13.5 # native esp-idf toolchain global cache dir +platformdirs==4.13.6 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg diff --git a/requirements_test.txt b/requirements_test.txt index de4dca96c7b..3a2f1776ba1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ pylint==4.1.3 -ruff==0.17.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.7 # .github/workflows/ci.yml reads this pin yamlrocks==0.7.1 # used by script/sync_dependency_versions.py diff --git a/tests/component_tests/ld6002b/test_button_dispatch.py b/tests/component_tests/ld6002b/test_button_dispatch.py index d6e9ca0aa4f..da00564a4cb 100644 --- a/tests/component_tests/ld6002b/test_button_dispatch.py +++ b/tests/component_tests/ld6002b/test_button_dispatch.py @@ -29,24 +29,25 @@ def _fn_body(main_cpp: str, fn: str) -> str: ("fn", "type_expr", "hub"), [ # Two blocks on hub_a: each named after its first button, chain ends in the fallback. + # The wake button is the model's own, so this block casts every type to uint8_t. ( "esphome__a_get_delay__button_fn", ( - "entity == ::a_get_delay ? ld6002b::ButtonType::GET_DELAY : " - "entity == ::a_get_sensitivity ? ld6002b::ButtonType::GET_SENSITIVITY : " - "ld6002b::ButtonType::WAKE" + "entity == ::a_get_delay ? static_cast(ld600x::BUTTON_GET_DELAY) : " + "entity == ::a_get_sensitivity ? static_cast(ld600x::BUTTON_GET_SENSITIVITY) : " + "static_cast(ld6002b::BUTTON_WAKE)" ), "hub_a", ), ( "esphome__a_get_z_range__button_fn", - "ld6002b::ButtonType::GET_Z_RANGE", + "ld600x::BUTTON_GET_Z_RANGE", "hub_a", ), # A single button on a second hub: no chain, its own hub. ( "esphome__b_get_installation__button_fn", - "ld6002b::ButtonType::GET_INSTALLATION", + "ld600x::BUTTON_GET_INSTALLATION", "hub_b", ), ], diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py index 5a666c7f186..ba7d9e568eb 100644 --- a/tests/component_tests/ld6002b/test_final_validate.py +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -9,17 +9,18 @@ from __future__ import annotations import pytest -from esphome.components.ld6002b import validate_unique_settings -from esphome.components.ld6002b.button import ( - CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, - FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, -) -from esphome.components.ld6002b.const import ( +from esphome.components.ld600x.const import ( CONF_AREA_CONFIG, CONF_HOLD_DELAY, CONF_Z_MAX, CONF_Z_MIN, ) +from esphome.components.ld600x.entities import unique_settings_final_validate +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_LD6002B_ID from esphome.components.ld6002b.number import ( CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, @@ -268,8 +269,9 @@ def test_setting_in_two_blocks_of_one_hub( full_config=_full_config(_hub(wakeup_pin=False), numbers=blocks), ) + validate = unique_settings_final_validate("number", CONF_LD6002B_ID, "ld6002b") if error is None: - validate_unique_settings("number", blocks[1]) + validate(blocks[1]) return with pytest.raises(cv.Invalid, match=error): - validate_unique_settings("number", blocks[1]) + validate(blocks[1]) diff --git a/tests/component_tests/ld600x/__init__.py b/tests/component_tests/ld600x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ld600x/test_entities.py b/tests/component_tests/ld600x/test_entities.py new file mode 100644 index 00000000000..ee392c2fd63 --- /dev/null +++ b/tests/component_tests/ld600x/test_entities.py @@ -0,0 +1,45 @@ +"""Model parameters control the shared area schemas and option ordering.""" + +import pytest + +from esphome.components.ld600x import LD600XComponent +from esphome.components.ld600x.entities import area_id_options, sensor_schema +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("with_dwell", [False, True]) +def test_area_id_options(with_dwell: bool) -> None: + kinds: tuple[str, ...] = ("interference", "detection") + expected: list[str] = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", + ] + if with_dwell: + kinds += ("dwell",) + expected += ["dwell_area_0", "dwell_area_1", "dwell_area_2", "dwell_area_3"] + assert area_id_options(kinds) == expected + + +@pytest.mark.parametrize("with_dwell", [False, True]) +def test_sensor_area_kinds( + set_core_config: SetCoreConfigCallable, with_dwell: bool +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + kinds: tuple[str, ...] = ("interference", "detection") + if with_dwell: + kinds += ("dwell",) + schema: cv.Schema = sensor_schema(LD600XComponent, "hub_id", area_kinds=kinds) + assert "interference_area_0" in schema({"interference_area_0": {}}) + if with_dwell: + assert "dwell_area_0" in schema({"dwell_area_0": {}}) + else: + with pytest.raises(cv.Invalid, match="dwell_area_0"): + schema({"dwell_area_0": {}}) diff --git a/tests/component_tests/ld600x/test_model_sizes.py b/tests/component_tests/ld600x/test_model_sizes.py new file mode 100644 index 00000000000..1a61455e679 --- /dev/null +++ b/tests/component_tests/ld600x/test_model_sizes.py @@ -0,0 +1,29 @@ +"""Repeated size requests for one hub keep the largest area kind count.""" + +from esphome.components.ld600x import request_model_sizes +from esphome.core import CORE +from esphome.cpp_generator import MockObj + + +def _defines() -> dict[str, str]: + return {define.name: str(define.value) for define in CORE.defines} + + +def test_requests_keep_the_largest_count() -> None: + hub_a = MockObj("hub_a") + hub_b = MockObj("hub_b") + request_model_sizes(hub_a, area_kinds=2) + request_model_sizes(hub_b, area_kinds=2) + request_model_sizes(hub_a, area_kinds=3) + request_model_sizes(hub_a, area_kinds=3) + request_model_sizes(hub_a, area_kinds=2) + CORE.flush_tasks() + defines = _defines() + assert defines["LD600X_AREA_KINDS"] == "3" + assert defines["LD600X_MAX_TARGETS"] == "3" + + +def test_two_area_kinds_by_default() -> None: + request_model_sizes(MockObj("hub"), area_kinds=2) + CORE.flush_tasks() + assert _defines()["LD600X_AREA_KINDS"] == "2" diff --git a/tests/component_tests/ota/test_ota_automations.py b/tests/component_tests/ota/test_ota_automations.py new file mode 100644 index 00000000000..957d1cc0171 --- /dev/null +++ b/tests/component_tests/ota/test_ota_automations.py @@ -0,0 +1,49 @@ +"""Tests for the on_* automations every OTA platform gets from ota_to_code.""" + +from __future__ import annotations + +from collections.abc import Callable + +from esphome.core import CORE + +AUTOMATIONS_YAML = "tests/component_tests/ota/test_ota_automations.yaml" +PLAIN_YAML = "tests/component_tests/ota/test_esphome_ota_plain.yaml" + + +def _code(main_cpp: str) -> str: + """The generated C++ without the YAML that generate_main echoes as comments.""" + return "\n".join( + line for line in main_cpp.splitlines() if not line.lstrip().startswith("//") + ) + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_ota_automations_generated(generate_main: Callable[[str], str]) -> None: + """Every key builds its automation on every platform and compiles in the + state notifications.""" + main_cpp = _code(generate_main(AUTOMATIONS_YAML)) + for message in ( + "native begin", + "native progress %.1f", + "native end", + "native abort", + "native error %u", + "native state %d", + "native second state", + "web begin", + "web abort", + "http end", + ): + assert message in main_cpp + assert "USE_OTA_STATE_LISTENER" in _defines() + + +def test_ota_without_automations_skips_state_listener( + generate_main: Callable[[str], str], +) -> None: + """Without automations or listeners the state notifications compile out.""" + generate_main(PLAIN_YAML) + assert "USE_OTA_STATE_LISTENER" not in _defines() diff --git a/tests/component_tests/ota/test_ota_automations.yaml b/tests/component_tests/ota/test_ota_automations.yaml new file mode 100644 index 00000000000..90908604772 --- /dev/null +++ b/tests/component_tests/ota/test_ota_automations.yaml @@ -0,0 +1,52 @@ +esphome: + name: test-ota-automations + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +logger: + +web_server: + port: 80 + +http_request: + verify_ssl: false + +ota: + - platform: esphome + id: ota_native + on_begin: + - logger.log: "native begin" + on_progress: + - logger.log: + format: "native progress %.1f" + args: ["x"] + on_end: + - logger.log: "native end" + on_abort: + - logger.log: "native abort" + on_error: + - logger.log: + format: "native error %u" + args: ["x"] + on_state_change: + - then: + - logger.log: + format: "native state %d" + args: ["static_cast(state)"] + - then: + - logger.log: "native second state" + - platform: web_server + id: ota_web + on_begin: + - logger.log: "web begin" + on_abort: + - logger.log: "web abort" + - platform: http_request + id: ota_http + on_end: + - logger.log: "http end" diff --git a/tests/component_tests/output/config/output_button.yaml b/tests/component_tests/output/config/output_button.yaml new file mode 100644 index 00000000000..1e50bb69499 --- /dev/null +++ b/tests/component_tests/output/config/output_button.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: gpio + id: relay + pin: GPIO4 + +button: + - platform: output + id: pulse_a + name: Pulse A + output: relay + duration: 500ms + - platform: output + id: pulse_b + name: Pulse B + output: relay + duration: 500ms + - platform: output + id: pulse_long + name: Pulse Long + output: relay + duration: 2s diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py index e93a0e02a02..43db942a03e 100644 --- a/tests/component_tests/output/test_output.py +++ b/tests/component_tests/output/test_output.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re from types import ModuleType import pytest @@ -95,6 +96,28 @@ def test_hub_channel_must_be_unique( module.FINAL_VALIDATE_SCHEMA(entries[3]) +def test_output_button_presses_through_shared_helper( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Each output button is an ApplyButton calling the helper with its output and duration.""" + main_cpp = generate_main(component_config_path("output_button.yaml")) + + fns = { + button_id: re.search( + rf"new\({button_id}\) button::ApplyButton<(\w+)>\(\);", main_cpp + ).group(1) + for button_id in ("pulse_a", "pulse_b", "pulse_long") + } + # Equal output and duration share one generated function + assert fns["pulse_a"] == fns["pulse_b"] != fns["pulse_long"] + for button_id, duration in (("pulse_a", 500), ("pulse_long", 2000)): + assert ( + f"static void {fns[button_id]}(button::Button * entity) {{\n" + f" output::press_output_button(entity, ::relay, {duration});\n}}" + ) in main_cpp + + def test_hub_channel_key_can_be_renamed(set_core_config: SetCoreConfigCallable) -> None: """A hub that names its channel `pin` passes channel_key and gets the same check.""" validate = output.final_validate_unique_channel( diff --git a/tests/component_tests/sun/__init__.py b/tests/component_tests/sun/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sun/test_parse_latlon.py b/tests/component_tests/sun/test_parse_latlon.py new file mode 100644 index 00000000000..6510e9fbad7 --- /dev/null +++ b/tests/component_tests/sun/test_parse_latlon.py @@ -0,0 +1,42 @@ +"""Tests for the sun component's latitude/longitude parsing.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.sun import parse_latlon + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("51.5", 51.5), + ("-74.0059", -74.0059), + ("40°26'46\"N", 40.44611111111111), + ("40°26'46\"S", -40.44611111111111), + ("22°57′7″S", -22.951944444444443), + ("22°57′7″s", -22.951944444444443), + ("30°", 30.0), + ("+40°N", 40.0), + # Surrounding whitespace, e.g. from a YAML block scalar + (" 40°26'46\"N\n", 40.44611111111111), + ], +) +def test_parse_latlon_valid(value: str, expected: float) -> None: + assert parse_latlon(value) == pytest.approx(expected) + + +@pytest.mark.parametrize( + "value", + [ + "abc", # unexpanded secret/garbage used to silently parse as 0.0 + "S30", # hemisphere letter before the number + "30°30.5'S", # decimal minutes are not supported: must error, not truncate + "", + "30 degrees", + "40°26'46\"N and some trailing text", + "-30°S", # a sign and a hemisphere letter used to cancel out + ], +) +def test_parse_latlon_invalid(value: str) -> None: + with pytest.raises(cv.Invalid): + parse_latlon(value) diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py index 4464e4c5308..480eb2c4be0 100644 --- a/tests/component_tests/uart/test_payload_tables.py +++ b/tests/component_tests/uart/test_payload_tables.py @@ -22,7 +22,14 @@ def test_constant_payloads_share_progmem_tables( ["{0x01, 0x02, 0x03}", "{0x4F, 0x4E}", "{0x04}"] ) assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp - assert f"shared_button->set_data({shared}, 3);" in main_cpp + # The button is an ApplyButton whose generated function writes the table from flash + fn = re.search(r"new\(shared_button\) button::ApplyButton<(\w+)>\(\);", main_cpp) + assert fn is not None + assert re.search( + rf"static void {fn.group(1)}\(button::Button \* entity\) \{{\n" + rf" ::\w+->write_array_progmem\({shared}, 3\);\n\}}", + main_cpp, + ) assert f"->write_array(::{shared}, 3);" in main_cpp assert "->write_array(nullptr, 0);" in main_cpp assert "->write_array(esphome__bytes.data(), esphome__bytes.size());" in main_cpp diff --git a/tests/component_tests/zephyr_mcumgr/__init__.py b/tests/component_tests/zephyr_mcumgr/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_mcumgr/config/ble_only.yaml b/tests/component_tests/zephyr_mcumgr/config/ble_only.yaml new file mode 100644 index 00000000000..77d074e8069 --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/ble_only.yaml @@ -0,0 +1,13 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/component_tests/zephyr_mcumgr/config/uart_only.yaml b/tests/component_tests/zephyr_mcumgr/config/uart_only.yaml new file mode 100644 index 00000000000..6a71c44f9b5 --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/uart_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +ota: + - platform: zephyr_mcumgr + transport: + hardware_uart: UART0 diff --git a/tests/component_tests/zephyr_mcumgr/config/udp_ipv4.yaml b/tests/component_tests/zephyr_mcumgr/config/udp_ipv4.yaml new file mode 100644 index 00000000000..2ee8ef24f8a --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/udp_ipv4.yaml @@ -0,0 +1,14 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +network: + enable_ipv4: true + +ota: + - platform: zephyr_mcumgr + transport: + udp: true diff --git a/tests/component_tests/zephyr_mcumgr/config/udp_ipv6.yaml b/tests/component_tests/zephyr_mcumgr/config/udp_ipv6.yaml new file mode 100644 index 00000000000..8172acf3c16 --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/udp_ipv6.yaml @@ -0,0 +1,13 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +network: + +ota: + - platform: zephyr_mcumgr + transport: + udp: true diff --git a/tests/component_tests/zephyr_mcumgr/config/udp_ipv6_disabled.yaml b/tests/component_tests/zephyr_mcumgr/config/udp_ipv6_disabled.yaml new file mode 100644 index 00000000000..d00b3599a87 --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/udp_ipv6_disabled.yaml @@ -0,0 +1,15 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +network: + enable_ipv6: false + enable_ipv4: true + +ota: + - platform: zephyr_mcumgr + transport: + udp: true diff --git a/tests/component_tests/zephyr_mcumgr/config/udp_no_network.yaml b/tests/component_tests/zephyr_mcumgr/config/udp_no_network.yaml new file mode 100644 index 00000000000..377ce713083 --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/config/udp_no_network.yaml @@ -0,0 +1,11 @@ +esphome: + name: mcumgrtest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +ota: + - platform: zephyr_mcumgr + transport: + udp: true diff --git a/tests/component_tests/zephyr_mcumgr/test_udp_transport.py b/tests/component_tests/zephyr_mcumgr/test_udp_transport.py new file mode 100644 index 00000000000..adfd067f2be --- /dev/null +++ b/tests/component_tests/zephyr_mcumgr/test_udp_transport.py @@ -0,0 +1,63 @@ +"""Tests for the prj.conf options the zephyr_mcumgr UDP transport writes.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF +from esphome.config import load_config +from esphome.core import CORE + +UDP_KEYS = ( + "CONFIG_MCUMGR_TRANSPORT_UDP", + "CONFIG_MCUMGR_TRANSPORT_UDP_IPV4", + "CONFIG_MCUMGR_TRANSPORT_UDP_MTU", + "CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE", +) + + +def _udp_values() -> tuple[object, ...]: + prj_conf = zephyr_data()[KEY_PRJ_CONF][""] + return tuple(prj_conf.get(key, (None,))[0] for key in UDP_KEYS) + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + # Thread's 1280 byte link MTU less the IPv6 (48) or IPv4 (28) and UDP headers + ("udp_ipv6.yaml", (True, False, 1232, 1232)), + ("udp_ipv4.yaml", (True, True, 1252, 1252)), + ("ble_only.yaml", (None, None, None, None)), + ("uart_only.yaml", (None, None, None, None)), + ], +) +def test_udp_prj_conf( + fixture: str, + expected: tuple[object, ...], + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + assert _udp_values() == expected + # BLE and UART need no network; UDP does + assert ("network" in CORE.config) is (expected[0] is True) + + +@pytest.mark.parametrize( + ("fixture", "message"), + [ + ( + "udp_ipv6_disabled.yaml", + "zephyr_mcumgr, but 'network: enable_ipv6: false' explicitly disables it", + ), + ("udp_no_network.yaml", "'udp' OTA transport requires a network"), + ], +) +def test_udp_rejected_without_ipv6( + fixture: str, message: str, component_config_path: Callable[[str], Path] +) -> None: + CORE.config_path = component_config_path(fixture) + res = load_config({}) + assert any(message in str(err) for err in res.errors), res.errors diff --git a/tests/components/a01nyub/common.h b/tests/components/a01nyub/common.h new file mode 100644 index 00000000000..32dc63d1131 --- /dev/null +++ b/tests/components/a01nyub/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::a01nyub::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::a01nyub::testing diff --git a/tests/components/a01nyub/sensor/a01nyub_test.cpp b/tests/components/a01nyub/sensor/a01nyub_test.cpp new file mode 100644 index 00000000000..98a2b805497 --- /dev/null +++ b/tests/components/a01nyub/sensor/a01nyub_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/a01nyub/a01nyub.h" +#include "../common.h" + +namespace esphome::a01nyub::testing { + +namespace { + +std::vector frame(uint16_t mm) { + uint8_t hi = mm >> 8, lo = mm & 0xFF; + return {0xFF, hi, lo, static_cast(0xFF + hi + lo)}; +} + +class A01nyubTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.add_on_state_callback([this](float state) { this->published_.push_back(state); }); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + MockUARTComponent uart_; + A01nyubComponent sut_; + std::vector published_; +}; + +} // namespace + +TEST_F(A01nyubTest, GoodFramePublishesMeters) { + this->feed_(frame(1953)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.953f); +} + +TEST_F(A01nyubTest, BadChecksumIsDropped) { + auto bad = frame(1953); + bad[3] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0.5f); +} + +TEST_F(A01nyubTest, DistanceAtOrBelowMinimumIsDropped) { + this->feed_(frame(280)); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(281)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0.281f); +} + +TEST_F(A01nyubTest, GarbageBeforeHeaderIsSkipped) { + this->feed_({0x01, 0x02, 0x7F}); + this->feed_(frame(1000)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.0f); +} + +TEST_F(A01nyubTest, SplitReadsAreJoined) { + auto f = frame(1234); + this->feed_({f[0], f[1]}); + EXPECT_TRUE(this->published_.empty()); + this->feed_({f[2], f[3]}); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.234f); +} + +TEST_F(A01nyubTest, BackToBackFrames) { + auto a = frame(1000), b = frame(2000); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_FLOAT_EQ(this->published_[1], 2.0f); +} + +// A rejected frame is dropped whole; the parser does not look for a header inside it. +TEST_F(A01nyubTest, StrayHeaderByteLosesFollowingFrame) { + auto f = frame(1000); + f.insert(f.begin(), 0xFF); + this->feed_(f); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(1500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.5f); +} + +} // namespace esphome::a01nyub::testing diff --git a/tests/components/a02yyuw/common.h b/tests/components/a02yyuw/common.h new file mode 100644 index 00000000000..913248231eb --- /dev/null +++ b/tests/components/a02yyuw/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::a02yyuw::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::a02yyuw::testing diff --git a/tests/components/a02yyuw/sensor/a02yyuw_test.cpp b/tests/components/a02yyuw/sensor/a02yyuw_test.cpp new file mode 100644 index 00000000000..c7f042a889f --- /dev/null +++ b/tests/components/a02yyuw/sensor/a02yyuw_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/a02yyuw/a02yyuw.h" +#include "../common.h" + +namespace esphome::a02yyuw::testing { + +namespace { + +std::vector frame(uint16_t mm) { + uint8_t hi = mm >> 8, lo = mm & 0xFF; + return {0xFF, hi, lo, static_cast(0xFF + hi + lo)}; +} + +class A02yyuwTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.add_on_state_callback([this](float state) { this->published_.push_back(state); }); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + MockUARTComponent uart_; + A02yyuwComponent sut_; + std::vector published_; +}; + +} // namespace + +TEST_F(A02yyuwTest, GoodFramePublishesMillimeters) { + this->feed_(frame(1953)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1953.0f); +} + +TEST_F(A02yyuwTest, BadChecksumIsDropped) { + auto bad = frame(1953); + bad[3] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 500.0f); +} + +TEST_F(A02yyuwTest, DistanceAtOrBelowMinimumIsDropped) { + this->feed_(frame(30)); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(31)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 31.0f); +} + +TEST_F(A02yyuwTest, GarbageBeforeHeaderIsSkipped) { + this->feed_({0x01, 0x02, 0x7F}); + this->feed_(frame(1000)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1000.0f); +} + +TEST_F(A02yyuwTest, SplitReadsAreJoined) { + auto f = frame(1234); + this->feed_({f[0], f[1]}); + EXPECT_TRUE(this->published_.empty()); + this->feed_({f[2], f[3]}); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1234.0f); +} + +TEST_F(A02yyuwTest, BackToBackFrames) { + auto a = frame(1000), b = frame(2000); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_FLOAT_EQ(this->published_[1], 2000.0f); +} + +// A rejected frame is dropped whole; the parser does not look for a header inside it. +TEST_F(A02yyuwTest, StrayHeaderByteLosesFollowingFrame) { + auto f = frame(1000); + f.insert(f.begin(), 0xFF); + this->feed_(f); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(1500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1500.0f); +} + +} // namespace esphome::a02yyuw::testing diff --git a/tests/components/ccs811/__init__.py b/tests/components/ccs811/__init__.py new file mode 100644 index 00000000000..b672a9ba46d --- /dev/null +++ b/tests/components/ccs811/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the sensor platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "text_sensor"] diff --git a/tests/components/ccs811/ccs811_env_data_test.cpp b/tests/components/ccs811/ccs811_env_data_test.cpp new file mode 100644 index 00000000000..b39a2334068 --- /dev/null +++ b/tests/components/ccs811/ccs811_env_data_test.cpp @@ -0,0 +1,72 @@ +#include + +#include + +#include "esphome/components/ccs811/ccs811.h" + +namespace esphome::ccs811::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x5A; +constexpr uint8_t STATUS_DATA_READY = 1 << 3; +constexpr uint8_t ENV_DATA_REG = 0x05; + +// Reports data ready, reads every other register as zero and keeps the last ENV_DATA write. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size == 0) + return i2c::ERROR_OK; + if (data[0] == ENV_DATA_REG && size == 5) + this->env_data.assign(data + 1, data + size); + for (size_t i = 0; i < out_size; i++) + out[i] = data[0] == 0x00 ? STATUS_DATA_READY : 0; + return i2c::ERROR_OK; + } + + std::vector env_data; +}; + +class CCS811EnvDataTest : public ::testing::Test { + protected: + void SetUp() override { + this->ccs811_.set_i2c_bus(&this->bus_); + this->ccs811_.set_i2c_address(ADDRESS); + this->ccs811_.set_humidity_sensor(&this->humidity_); + this->ccs811_.set_temperature_sensor(&this->temperature_); + } + + std::vector send_(float humidity, float temperature) { + this->humidity_.state = humidity; + this->temperature_.state = temperature; + this->ccs811_.update(); + return this->bus_.env_data; + } + + FakeBus bus_; + sensor::Sensor humidity_; + sensor::Sensor temperature_; + CCS811Component ccs811_; +}; + +// ENV_DATA holds each value in 1/512 steps, temperature offset by 25 °C; 50 %RH and 25 °C are both 0x6400. +} // namespace + +TEST_F(CCS811EnvDataTest, DatasheetDefaultsEncodeExactly) { + EXPECT_EQ(this->send_(50.0f, 25.0f), (std::vector{0x64, 0x00, 0x64, 0x00})); +} + +TEST_F(CCS811EnvDataTest, FractionsRoundToNearestStep) { + // 42.348 %RH = 21682.2 steps, 20.5 °C + 25 = 23296 steps + EXPECT_EQ(this->send_(42.348f, 20.5f), (std::vector{0x54, 0xB2, 0x5B, 0x00})); + // 33.9995 %RH = 17407.7 steps rounds to 34 %RH; -0.0005 °C + 25 = 12799.7 steps rounds to 25 °C + EXPECT_EQ(this->send_(33.9995f, -0.0005f), (std::vector{0x44, 0x00, 0x32, 0x00})); +} + +TEST_F(CCS811EnvDataTest, MissingValuesUseDefaults) { + EXPECT_EQ(this->send_(NAN, NAN), (std::vector{0x64, 0x00, 0x64, 0x00})); +} + +} // namespace esphome::ccs811::testing diff --git a/tests/components/core/test_publish_optional.cpp b/tests/components/core/test_publish_optional.cpp new file mode 100644 index 00000000000..7f783976125 --- /dev/null +++ b/tests/components/core/test_publish_optional.cpp @@ -0,0 +1,50 @@ +#include +#include +#include + +#include "esphome/core/entity_base.h" + +namespace esphome::testing { + +struct FakeSensor { + void publish_state(float state) { this->states.push_back(state); } + std::vector states; +}; + +struct FakeBinarySensor { + void publish_state(bool state) { this->states.push_back(state); } + std::vector states; +}; + +TEST(PublishOptional, PublishesEverySetValue) { + FakeSensor sensor; + publish_optional(&sensor, optional(21.5f)); + publish_optional(&sensor, optional(0.0f)); + publish_optional(&sensor, optional(NAN)); + ASSERT_EQ(sensor.states.size(), 3u); + EXPECT_FLOAT_EQ(sensor.states[0], 21.5f); + EXPECT_FLOAT_EQ(sensor.states[1], 0.0f); + EXPECT_TRUE(std::isnan(sensor.states[2])); +} + +TEST(PublishOptional, SkipsAnEmptyValue) { + FakeSensor sensor; + publish_optional(&sensor, optional()); + EXPECT_TRUE(sensor.states.empty()); +} + +TEST(PublishOptional, IgnoresAMissingEntity) { + FakeSensor *sensor = nullptr; + publish_optional(sensor, optional(21.5f)); + publish_optional(sensor, optional()); +} + +TEST(PublishOptional, WorksForOtherEntityTypes) { + FakeBinarySensor binary_sensor; + publish_optional(&binary_sensor, optional(false)); + publish_optional(&binary_sensor, optional()); + publish_optional(&binary_sensor, optional(true)); + EXPECT_EQ(binary_sensor.states, (std::vector{false, true})); +} + +} // namespace esphome::testing diff --git a/tests/components/ezo/__init__.py b/tests/components/ezo/__init__.py new file mode 100644 index 00000000000..8cb623e51b6 --- /dev/null +++ b/tests/components/ezo/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the sensor platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/ezo/test_ezo_commands.cpp b/tests/components/ezo/test_ezo_commands.cpp new file mode 100644 index 00000000000..18c32a802d8 --- /dev/null +++ b/tests/components/ezo/test_ezo_commands.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/ezo/ezo.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::ezo::testing { + +static constexpr uint8_t ADDRESS = 99; + +// Records each command written and answers each read with a success code and the reply set for the last command. +class EzoCommandBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + this->addresses.push_back(address); + if (size != 0) + this->writes.emplace_back(reinterpret_cast(data), size); + if (out_size != 0) { + memset(out, 0, out_size); + out[0] = 1; + auto it = this->replies.find(this->writes.empty() ? std::string() : this->writes.back()); + const std::string &reply = it == this->replies.end() ? this->default_reply : it->second; + std::copy_n(reply.data(), std::min(reply.size(), out_size - 1), out + 1); + } + return i2c::ERROR_OK; + } + + std::vector writes; + std::vector addresses; + std::map replies; + std::string default_reply{"7.00"}; +}; + +class EzoCommandTest : public ::testing::Test { + protected: + void SetUp() override { + this->ezo_.set_i2c_bus(&this->bus_); + this->ezo_.set_i2c_address(ADDRESS); + } + + // Runs the loop on the real clock until `count` commands were written and answered. + void run_until_written_(size_t count) { + // Longer than the slowest reply wait (900 ms), so the last reply has arrived. + static constexpr uint32_t SETTLE_MS = 1000; + const uint32_t start = millis(); + uint32_t written_at = start; + while (millis() - start < 30000) { + { + LoopBlockingGuard guard{nullptr, nullptr, millis()}; + this->ezo_.loop(); + } + if (this->bus_.writes.size() < count) { + written_at = millis(); + } else if (millis() - written_at > SETTLE_MS) { + break; + } + delay(5); + } + } + + EzoCommandBus bus_; + EZOSensor ezo_; +}; + +TEST_F(EzoCommandTest, WireTextOfEachCommand) { + this->ezo_.get_device_information(); + this->ezo_.set_sleep(); + this->ezo_.get_slope(); + this->ezo_.get_t(); + this->ezo_.set_t(21.5f); + this->ezo_.get_calibration(); + this->ezo_.clear_calibration(); + this->ezo_.set_calibration_point_low(4.0f); + this->run_until_written_(8); + this->ezo_.set_tempcomp_value(-5.256f); + this->ezo_.set_calibration_point_mid(7.0f); + this->ezo_.set_calibration_point_high(10.0f); + this->ezo_.set_calibration_generic(1413.0f); + this->ezo_.get_led_state(); + this->ezo_.set_led_state(false); + this->ezo_.set_led_state(true); + this->ezo_.send_custom("Name,?"); + this->run_until_written_(16); + this->ezo_.get_state(); + this->run_until_written_(17); + EXPECT_EQ(this->bus_.writes, + (std::vector{"i", "Sleep", "Slope,?", "T,?", "T,21.50", "Cal,?", "Cal,clear", "Cal,LOW,4.00", + "T,-5.26", "Cal,MID,7.00", "Cal,HIGH,10.00", "Cal,1413.00", "L,?", "L,0", "L,1", + "Name,?", "R"})); +} + +TEST_F(EzoCommandTest, AddressChangeAppliesToLaterCommands) { + this->ezo_.set_address(100); + this->ezo_.get_led_state(); + this->run_until_written_(2); + EXPECT_EQ(this->bus_.writes, (std::vector{"I2C,100", "L,?"})); + // I2C,100 goes to the old address with no reply read; L,? is written and read at the new one. + EXPECT_EQ(this->bus_.addresses, (std::vector{ADDRESS, 100, 100})); +} + +TEST_F(EzoCommandTest, InvalidAddressIsIgnored) { + this->ezo_.set_address(0); + this->ezo_.set_address(128); + this->ezo_.get_led_state(); + this->run_until_written_(1); + EXPECT_EQ(this->bus_.writes, (std::vector{"L,?"})); + EXPECT_EQ(this->bus_.addresses, (std::vector{ADDRESS, ADDRESS})); +} + +} // namespace esphome::ezo::testing diff --git a/tests/components/gcja5/common.h b/tests/components/gcja5/common.h new file mode 100644 index 00000000000..f80e2f78871 --- /dev/null +++ b/tests/components/gcja5/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::gcja5::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::gcja5::testing diff --git a/tests/components/gcja5/sensor/gcja5_test.cpp b/tests/components/gcja5/sensor/gcja5_test.cpp new file mode 100644 index 00000000000..37edee7448c --- /dev/null +++ b/tests/components/gcja5/sensor/gcja5_test.cpp @@ -0,0 +1,134 @@ +#include + +#include +#include + +#include "esphome/components/gcja5/gcja5.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/application.h" +#include "../common.h" + +namespace esphome::gcja5::testing { + +namespace { + +std::vector frame(uint32_t pm25, uint16_t pmc10) { + std::vector f(32, 0x00); + f[0] = 0x02; + f[5] = pm25; + f[6] = pm25 >> 8; + f[7] = pm25 >> 16; + f[8] = pm25 >> 24; + f[25] = pmc10; + f[26] = pmc10 >> 8; + uint8_t fcc = 0; + for (size_t i = 1; i < 30; i++) + fcc ^= f[i]; + f[30] = fcc; + f[31] = 0x03; + return f; +} + +class GCJA5Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_pm_2_5_sensor(&this->pm_2_5_); + this->sut_.set_pmc_10_0_sensor(&this->pmc_10_0_); + this->pm_2_5_.add_on_state_callback([this](float state) { this->pm_2_5_published_.push_back(state); }); + this->pmc_10_0_.add_on_state_callback([this](float state) { this->pmc_10_0_published_.push_back(state); }); + } + + void feed_(const std::vector &bytes, uint32_t now = 0) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + LoopBlockingGuard{nullptr, nullptr, now}; + this->sut_.loop(); + } + + MockUARTComponent uart_; + GCJA5Component sut_; + sensor::Sensor pm_2_5_; + sensor::Sensor pmc_10_0_; + std::vector pm_2_5_published_; + std::vector pmc_10_0_published_; +}; + +} // namespace + +TEST_F(GCJA5Test, GoodFramePublishes) { + this->feed_(frame(0x00012345, 0x0456)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + ASSERT_EQ(this->pmc_10_0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 0x00012345); + EXPECT_FLOAT_EQ(this->pmc_10_0_published_[0], 0x0456); +} + +TEST_F(GCJA5Test, BadChecksumIsDropped) { + auto bad = frame(10, 20); + bad[30] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + EXPECT_TRUE(this->sut_.status_has_warning()); + this->feed_(frame(11, 21)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 11); +} + +TEST_F(GCJA5Test, BadEndByteIsDropped) { + auto bad = frame(10, 20); + bad[31] = 0x00; + this->feed_(bad); + 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) { + 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); +} + +TEST_F(GCJA5Test, SplitReadsAreJoined) { + auto f = frame(30, 40); + this->feed_(std::vector(f.begin(), f.begin() + 10), 0); + this->feed_(std::vector(f.begin() + 10, f.begin() + 20), 200); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(std::vector(f.begin() + 20, f.end()), 400); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 30); +} + +TEST_F(GCJA5Test, BackToBackFrames) { + auto a = frame(1, 2), b = frame(3, 4); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->pm_2_5_published_.size(), 2u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[1], 3); +} + +// There is no header search: a stray byte misaligns every frame until a 500 ms pause clears the buffer +TEST_F(GCJA5Test, GarbageBeforeFrameMisalignsUntilPause) { + std::vector bytes{0x55}; + for (int i = 0; i < 3; i++) { + auto f = frame(50 + i, 60); + bytes.insert(bytes.end(), f.begin(), f.end()); + } + this->feed_(bytes, 1000); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(70, 80), 1500); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 70); +} + +TEST_F(GCJA5Test, PauseDropsPartialFrame) { + auto f = frame(30, 40); + this->feed_(std::vector(f.begin(), f.begin() + 10), 1000); + this->feed_(frame(31, 41), 1500); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 31); +} + +} // namespace esphome::gcja5::testing diff --git a/tests/components/hte501/__init__.py b/tests/components/hte501/__init__.py new file mode 100644 index 00000000000..8cb623e51b6 --- /dev/null +++ b/tests/components/hte501/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the sensor platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/hte501/hte501_i2c_error_test.cpp b/tests/components/hte501/hte501_i2c_error_test.cpp new file mode 100644 index 00000000000..27bf9f4a0e8 --- /dev/null +++ b/tests/components/hte501/hte501_i2c_error_test.cpp @@ -0,0 +1,90 @@ +#include + +#include + +#include "esphome/components/hte501/hte501.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::hte501::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x40; + +// Always fills the read buffer with a valid answer, so a transfer reported as failed still carries a +// frame that passes the CRC check. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + // 24.00 °C, 50.00 %RH + static constexpr uint8_t MEASUREMENT[2][2] = {{0x09, 0x60}, {0x13, 0x88}}; + for (size_t i = 0; i + 3 <= out_size; i += 3) { + const uint8_t *word = MEASUREMENT[i / 3 % 2]; + out[i] = word[0]; + out[i + 1] = word[1]; + out[i + 2] = crc8(word, 2, 0xFF, 0x31, true); + } + if (out_size == 9) // identification: CRC over all 8 bytes + out[8] = crc8(out, 8, 0xFF, 0x31, true); + if (size != 0) + return this->fail_writes ? i2c::ERROR_NOT_ACKNOWLEDGED : i2c::ERROR_OK; + return this->fail_reads ? i2c::ERROR_TIMEOUT : i2c::ERROR_OK; + } + + bool fail_writes{false}; + bool fail_reads{false}; +}; + +class HTE501I2CErrorTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.set_i2c_bus(&this->bus_); + this->sensor_.set_i2c_address(ADDRESS); + this->sensor_.set_temperature_sensor(&this->temperature_); + } + + // update() sends the measure command and reads the answer 50 ms later. + void measure_() { + this->temperature_.state = NAN; + this->sensor_.update(); + delay(60); // NOLINT + App.scheduler.call(millis()); + } + + FakeBus bus_; + HTE501Component sensor_; + sensor::Sensor temperature_; +}; + +} // namespace + +TEST_F(HTE501I2CErrorTest, MeasuresWhenTheBusWorks) { + this->measure_(); + EXPECT_FLOAT_EQ(this->temperature_.state, 24.0f); + EXPECT_FALSE(this->sensor_.status_has_warning()); +} + +TEST_F(HTE501I2CErrorTest, SetupFailsWhenIdentificationReadFails) { + this->bus_.fail_writes = true; + this->sensor_.setup(); + EXPECT_TRUE(this->sensor_.is_failed()); +} + +TEST_F(HTE501I2CErrorTest, FailedCommandSkipsTheReading) { + this->bus_.fail_writes = true; + this->measure_(); + EXPECT_TRUE(std::isnan(this->temperature_.state)); + EXPECT_TRUE(this->sensor_.status_has_warning()); +} + +TEST_F(HTE501I2CErrorTest, FailedReadSkipsTheReading) { + this->bus_.fail_reads = true; + this->measure_(); + EXPECT_TRUE(std::isnan(this->temperature_.state)); + EXPECT_TRUE(this->sensor_.status_has_warning()); +} + +} // namespace esphome::hte501::testing diff --git a/tests/components/hte501/hte501_temperature_test.cpp b/tests/components/hte501/hte501_temperature_test.cpp new file mode 100644 index 00000000000..3f6e155bc91 --- /dev/null +++ b/tests/components/hte501/hte501_temperature_test.cpp @@ -0,0 +1,68 @@ +#include + +#include "esphome/components/hte501/hte501.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::hte501::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x40; + +// Answers a measurement read with the raw temperature word (and 50 %RH), each followed by its CRC. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + const uint8_t words[2][2] = {{static_cast(this->raw >> 8), static_cast(this->raw)}, {0x13, 0x88}}; + for (size_t i = 0; i + 3 <= out_size; i += 3) { + out[i] = words[i / 3][0]; + out[i + 1] = words[i / 3][1]; + out[i + 2] = crc8(words[i / 3], 2, 0xFF, 0x31, true); + } + return i2c::ERROR_OK; + } + + uint16_t raw{0}; +}; + +class HTE501TemperatureTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.set_i2c_bus(&this->bus_); + this->sensor_.set_i2c_address(ADDRESS); + this->sensor_.set_temperature_sensor(&this->temperature_); + } + + // update() sends the measure command and reads the answer 50 ms later. + float measure_(uint16_t raw) { + this->bus_.raw = raw; + this->sensor_.update(); + delay(60); // NOLINT + App.scheduler.call(millis()); + return this->temperature_.state; + } + + FakeBus bus_; + HTE501Component sensor_; + sensor::Sensor temperature_; +}; + +// The sensor sends hundredths of a degree as a signed 16-bit value. +} // namespace + +TEST_F(HTE501TemperatureTest, DecodesSignedHundredths) { + EXPECT_FLOAT_EQ(this->measure_(0x0960), 24.0f); + EXPECT_FLOAT_EQ(this->measure_(0x34BC), 135.0f); + EXPECT_FLOAT_EQ(this->measure_(0xFFFF), -0.01f); + EXPECT_FLOAT_EQ(this->measure_(0xF060), -40.0f); +} + +TEST_F(HTE501TemperatureTest, DecodesTheWholeSignedRange) { + EXPECT_FLOAT_EQ(this->measure_(0xD8F0), -100.0f); + EXPECT_FLOAT_EQ(this->measure_(0x8000), -327.68f); +} + +} // namespace esphome::hte501::testing diff --git a/tests/components/jsn_sr04t/common.h b/tests/components/jsn_sr04t/common.h new file mode 100644 index 00000000000..d1bad591d6a --- /dev/null +++ b/tests/components/jsn_sr04t/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::jsn_sr04t::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::jsn_sr04t::testing diff --git a/tests/components/jsn_sr04t/sensor/jsn_sr04t_test.cpp b/tests/components/jsn_sr04t/sensor/jsn_sr04t_test.cpp new file mode 100644 index 00000000000..df9391fc4d6 --- /dev/null +++ b/tests/components/jsn_sr04t/sensor/jsn_sr04t_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include + +#include "esphome/components/jsn_sr04t/jsn_sr04t.h" +#include "../common.h" + +namespace esphome::jsn_sr04t::testing { + +namespace { + +std::vector frame(uint16_t mm) { + uint8_t hi = mm >> 8, lo = mm & 0xFF; + return {0xFF, hi, lo, static_cast(0xFF + hi + lo)}; +} + +class Jsnsr04tTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_model(JSN_SR04T); + this->sut_.add_on_state_callback([this](float state) { this->published_.push_back(state); }); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + MockUARTComponent uart_; + Jsnsr04tComponent sut_; + std::vector published_; +}; + +} // namespace + +TEST_F(Jsnsr04tTest, GoodFramePublishesMeters) { + this->feed_(frame(1953)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.953f); +} + +TEST_F(Jsnsr04tTest, BadChecksumIsDropped) { + auto bad = frame(1953); + bad[3] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0.5f); +} + +TEST_F(Jsnsr04tTest, DistanceAtOrBelowMinimumIsDropped) { + this->feed_(frame(250)); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(251)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0.251f); +} + +TEST_F(Jsnsr04tTest, Ajsr04mHasLowerMinimum) { + this->sut_.set_model(AJ_SR04M); + this->feed_(frame(200)); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(201)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0.201f); +} + +TEST_F(Jsnsr04tTest, GarbageBeforeHeaderIsSkipped) { + this->feed_({0x01, 0x02, 0x7F}); + this->feed_(frame(1000)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.0f); +} + +TEST_F(Jsnsr04tTest, SplitReadsAreJoined) { + auto f = frame(1234); + this->feed_({f[0], f[1]}); + EXPECT_TRUE(this->published_.empty()); + this->feed_({f[2], f[3]}); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.234f); +} + +TEST_F(Jsnsr04tTest, BackToBackFrames) { + auto a = frame(1000), b = frame(2000); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_FLOAT_EQ(this->published_[1], 2.0f); +} + +// A rejected frame is dropped whole; the parser does not look for a header inside it. +TEST_F(Jsnsr04tTest, StrayHeaderByteLosesFollowingFrame) { + auto f = frame(1000); + f.insert(f.begin(), 0xFF); + this->feed_(f); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(1500)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 1.5f); +} + +} // namespace esphome::jsn_sr04t::testing diff --git a/tests/components/ld6002b/binary_sensor/ld6002b_binary_sensor_test.cpp b/tests/components/ld6002b/binary_sensor/ld6002b_binary_sensor_test.cpp new file mode 100644 index 00000000000..c85e68962a6 --- /dev/null +++ b/tests/components/ld6002b/binary_sensor/ld6002b_binary_sensor_test.cpp @@ -0,0 +1,67 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6002b::testing { + +namespace { + +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); +} + +} // namespace + +TEST_F(LD6002BTest, TargetReportSetsPresence) { + this->feed_(target_report({{1.0f, 1.0f, 1.0f, 0, 4}})); + EXPECT_TRUE(this->presence_.state); + EXPECT_TRUE(this->target_presence_[0].state); + EXPECT_FALSE(this->target_presence_[1].state); + EXPECT_TRUE(this->target_presence_[1].has_state()); + + this->feed_(target_report({})); + EXPECT_FALSE(this->presence_.state); + EXPECT_FALSE(this->target_presence_[0].state); +} + +TEST_F(LD6002BTest, 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); + + this->feed_(area_presence({0, 0, 0, 0})); + EXPECT_FALSE(this->area_presence_[2].state); + EXPECT_FALSE(this->presence_.state); +} + +TEST_F(LD6002BTest, AreaPresenceKeepsPresenceWhileTargetsAreGone) { + this->feed_(area_presence({1, 0, 0, 0})); + this->feed_(target_report({})); + EXPECT_TRUE(this->presence_.state); +} + +TEST_F(LD6002BTest, ShortAreaPresenceReportIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_AREA_PRESENCE, std::vector(15, 1))); + EXPECT_FALSE(this->area_presence_[0].has_state()); +} + +TEST_F(LD6002BTest, UnattendedWorkModeClearsAreaPresence) { + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {0x00})); + EXPECT_FALSE(this->area_presence_[1].state); + EXPECT_FALSE(this->presence_.state); +} + +TEST_F(LD6002BTest, UnattendedWorkModeKeepsAreaPresenceWhileTracking) { + this->feed_(target_report({{1.0f, 1.0f, 1.0f, 0, 4}})); + this->feed_(area_presence({0, 1, 0, 0})); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {0x00})); + EXPECT_TRUE(this->area_presence_[1].state); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/common.h b/tests/components/ld6002b/common.h new file mode 100644 index 00000000000..139bc81bffa --- /dev/null +++ b/tests/components/ld6002b/common.h @@ -0,0 +1,297 @@ +#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/ld6002b/ld6002b.h" +#include "esphome/components/ld600x/ld600x_frame.h" +#include "esphome/components/number/number.h" +#include "esphome/components/select/select.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/text_sensor/text_sensor.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::ld6002b::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_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; +static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; + +static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +// Every frame this component sends has the top bit of its id set. +static constexpr uint16_t FIRST_COMMAND_ID = 0x8000; + +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; +} + +struct Target { + float x; + float y; + float z; + int32_t dop_idx; + int32_t cluster_id; +}; + +inline std::vector target_report(const std::vector &targets) { + std::vector p; + put_u32(p, targets.size()); + for (const auto &t : targets) { + put_f32(p, t.x); + put_f32(p, t.y); + put_f32(p, t.z); + put_u32(p, static_cast(t.dop_idx)); + put_u32(p, static_cast(t.cluster_id)); + } + return tf_frame(TYPE_REPORT_TARGET, 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 concat(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// The generated apply/publish="before" entities forward to the hub through this pointer. +inline LD6002BComponent *&test_hub() { + static LD6002BComponent *hub = nullptr; + return hub; +} + +inline void apply_number(number::Number *entity, float value) { + entity->publish_state(value); + test_hub()->set_number_value(entity, value); +} +inline void apply_select(select::Select *entity, size_t index) { + entity->publish_state(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); +} + +using TestNumber = number::ApplyNumber; +using TestSelect = select::ApplySelect; +using TestSwitch = switch_::ApplySwitch; + +/// A hub with one entity of every kind, set up as codegen does and past its boot timeout. +class LD6002BTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = std::filesystem::temp_directory_path() / "esphome_ld6002b_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_; + test_hub() = &this->sut_; + + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_target_count_sensor(&this->target_count_); + this->sut_.set_point_count_sensor(&this->point_count_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + this->sut_.set_target_x_sensor(i, &this->target_x_[i]); + this->sut_.set_target_y_sensor(i, &this->target_y_[i]); + this->sut_.set_target_z_sensor(i, &this->target_z_[i]); + this->sut_.set_target_dop_idx_sensor(i, &this->target_dop_[i]); + this->sut_.set_target_cluster_id_sensor(i, &this->target_cluster_[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]); + // Interference area 0 and detection area 1, so a report has to land on the right area + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_X_MIN, &this->interference_0_[0]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_X_MAX, &this->interference_0_[1]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_Y_MIN, &this->interference_0_[2]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_Y_MAX, &this->interference_0_[3]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_Z_MIN, &this->interference_0_[4]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_INTERFERENCE, 0, ld600x::AREA_AXIS_Z_MAX, &this->interference_0_[5]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_X_MIN, &this->detection_1_[0]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_X_MAX, &this->detection_1_[1]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_Y_MIN, &this->detection_1_[2]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_Y_MAX, &this->detection_1_[3]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_Z_MIN, &this->detection_1_[4]); + this->sut_.set_area_sensor(ld600x::AREA_KIND_DETECTION, 1, ld600x::AREA_AXIS_Z_MAX, &this->detection_1_[5]); + this->sut_.set_presence_binary_sensor(&this->presence_); + this->sut_.set_work_mode_text_sensor(&this->work_mode_); + this->sut_.set_ota_version_text_sensor(&this->ota_version_); + + this->hold_delay_.traits.set_min_value(0); + this->hold_delay_.traits.set_max_value(65535); + this->sut_.set_hold_delay_number(&this->hold_delay_); + for (auto *z : {&this->z_min_, &this->z_max_}) { + z->traits.set_min_value(-10); + z->traits.set_max_value(10); + } + this->sut_.set_z_min_number(&this->z_min_); + this->sut_.set_z_max_number(&this->z_max_); + this->low_power_sleep_.traits.set_min_value(0); + this->low_power_sleep_.traits.set_max_value(60000); + this->sut_.set_low_power_sleep_number(&this->low_power_sleep_); + + this->sensitivity_.traits.set_options({"low", "medium", "high"}); + this->sut_.set_sensitivity_select(&this->sensitivity_); + this->trigger_speed_.traits.set_options({"slow", "medium", "fast"}); + this->sut_.set_trigger_speed_select(&this->trigger_speed_); + this->installation_.traits.set_options({"top", "side"}); + this->sut_.set_installation_select(&this->installation_); + this->sut_.set_low_power_switch(&this->low_power_); + + this->sensor_log_(this->target_count_, this->target_count_published_); + this->sensor_log_(this->point_count_, this->point_count_published_); + this->sensor_log_(this->target_x_[0], this->target_x0_published_); + + 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 { + test_hub() = nullptr; + host::setup_preferences(); + unsetenv("ESPHOME_PREFDIR"); + std::filesystem::remove_all(this->temp_dir_); + } + + void sensor_log_(sensor::Sensor &sensor, std::vector &log) { + sensor.add_on_state_callback([&log](float state) { log.push_back(state); }); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + /// ACK every boot command in turn until the queue is empty; returns the id the next command gets. + uint16_t drain_commands_() { + uint16_t next_id = FIRST_COMMAND_ID; + for (int i = 0; i < 32 && this->uart_.tx.size() >= ld600x::TF_FRAME_OVERHEAD; i++) { + const uint16_t type = encode_uint16(this->uart_.tx[5], this->uart_.tx[6]); + this->uart_.tx.clear(); + this->feed_(ack(type)); + next_id++; + } + EXPECT_TRUE(this->uart_.tx.empty()); + return next_id; + } + + host::HostPreferences prefs_; + std::filesystem::path temp_dir_; + MockUARTComponent uart_; + LD6002BComponent sut_; + + sensor::Sensor target_count_; + sensor::Sensor point_count_; + sensor::Sensor target_x_[MAX_TARGETS]; + sensor::Sensor target_y_[MAX_TARGETS]; + sensor::Sensor target_z_[MAX_TARGETS]; + sensor::Sensor target_dop_[MAX_TARGETS]; + sensor::Sensor target_cluster_[MAX_TARGETS]; + // x_min, x_max, y_min, y_max, z_min, z_max + sensor::Sensor interference_0_[6]; + sensor::Sensor detection_1_[6]; + binary_sensor::BinarySensor presence_; + binary_sensor::BinarySensor target_presence_[MAX_TARGETS]; + binary_sensor::BinarySensor area_presence_[AREA_COUNT]; + text_sensor::TextSensor work_mode_; + text_sensor::TextSensor ota_version_; + TestNumber hold_delay_; + TestNumber z_min_; + TestNumber z_max_; + TestNumber low_power_sleep_; + TestSelect sensitivity_; + TestSelect trigger_speed_; + TestSelect installation_; + TestSwitch low_power_; + + std::vector target_count_published_; + std::vector point_count_published_; + std::vector target_x0_published_; +}; + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/ld6002b_uart_test.cpp b/tests/components/ld6002b/ld6002b_uart_test.cpp new file mode 100644 index 00000000000..c5e7df5c1d6 --- /dev/null +++ b/tests/components/ld6002b/ld6002b_uart_test.cpp @@ -0,0 +1,141 @@ +#ifdef USE_HOST +#include "common.h" + +namespace esphome::ld6002b::testing { + +namespace { + +// One target at x, so every accepted report shows up on target_x_[0] +std::vector target_at(float x) { return target_report({{x, 0.5f, 1.0f, 0, 3}}); } + +} // namespace + +TEST_F(LD6002BTest, BootStartsTargetStreamFirst) { + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +TEST_F(LD6002BTest, AckReleasesNextQueuedCommand) { + 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_ON))); +} + +TEST_F(LD6002BTest, ReportDoesNotReleaseNextCommand) { + this->uart_.tx.clear(); + this->feed_(target_at(1.0f)); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(LD6002BTest, AckOfAnotherTypeDoesNotReleaseNextCommand) { + this->uart_.tx.clear(); + this->feed_(ack(TYPE_SET_HOLD_DELAY)); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(LD6002BTest, UnansweredCommandIsResentWithNewId) { + this->uart_.tx.clear(); + // The first attempt waits 600 ms, since a sleeping module only wakes on it + LoopBlockingGuard clock{nullptr, nullptr, App.get_loop_component_start_time() + 650}; + this->feed_({}); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID + 1, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +TEST_F(LD6002BTest, SelectWritesControlCommand) { + const uint16_t id = this->drain_commands_(); + this->sensitivity_.make_call().set_index(2).perform(); + EXPECT_EQ(this->uart_.tx, command(id, TYPE_CONTROL, u32_payload(CMD_SENSITIVITY_HIGH))); +} + +TEST_F(LD6002BTest, HoldDelayNumberWritesLittleEndianValue) { + const uint16_t id = this->drain_commands_(); + this->hold_delay_.make_call().set_value(300).perform(); + EXPECT_EQ(this->uart_.tx, command(id, TYPE_SET_HOLD_DELAY, {0x2C, 0x01, 0x00, 0x00})); +} + +TEST_F(LD6002BTest, ZRangeWritesBothBoundsAsLittleEndianFloats) { + this->feed_(tf_frame(TYPE_REPORT_Z_RANGE, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40})); + const uint16_t id = this->drain_commands_(); + this->z_max_.make_call().set_value(2.5f).perform(); + // 0.0f and 2.5f (0x40200000) + EXPECT_EQ(this->uart_.tx, command(id, TYPE_SET_Z_RANGE, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x40})); +} + +enum class Corruption : uint8_t { CORRUPTION_HEADER_CHECKSUM, CORRUPTION_PAYLOAD_BYTE, CORRUPTION_DATA_CHECKSUM }; + +class LD6002BCorruptFrameTest : public LD6002BTest, public ::testing::WithParamInterface {}; + +// A frame with one flipped byte is dropped, and the next good frame still parses +TEST_P(LD6002BCorruptFrameTest, IsDroppedAndNextFrameRecovers) { + auto bad = target_at(1.0f); + switch (GetParam()) { + case Corruption::CORRUPTION_HEADER_CHECKSUM: + bad[ld600x::TF_HEADER_LEN + 1] ^= 0x10; + break; + case Corruption::CORRUPTION_PAYLOAD_BYTE: + bad[ld600x::TF_HEADER_LEN + 2 + 4] ^= 0x01; + break; + case Corruption::CORRUPTION_DATA_CHECKSUM: + bad.back() ^= 0x10; + break; + } + this->feed_(bad); + EXPECT_TRUE(this->target_x0_published_.empty()); + this->feed_(target_at(2.0f)); + ASSERT_EQ(this->target_x0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->target_x0_published_[0], 2.0f); +} + +INSTANTIATE_TEST_SUITE_P(Corruptions, LD6002BCorruptFrameTest, + ::testing::Values(Corruption::CORRUPTION_HEADER_CHECKSUM, Corruption::CORRUPTION_PAYLOAD_BYTE, + Corruption::CORRUPTION_DATA_CHECKSUM)); + +TEST_F(LD6002BTest, GarbageBetweenFramesIsSkipped) { + this->feed_(concat(concat(target_at(1.0f), {0x00, 0xFF, 0x55, 0xAA}), target_at(2.0f))); + ASSERT_EQ(this->target_x0_published_.size(), 2u); + EXPECT_FLOAT_EQ(this->target_x0_published_[0], 1.0f); + EXPECT_FLOAT_EQ(this->target_x0_published_[1], 2.0f); +} + +TEST_F(LD6002BTest, BackToBackFramesInOneRead) { + this->feed_(concat(target_at(1.0f), target_at(2.0f))); + ASSERT_EQ(this->target_x0_published_.size(), 2u); + EXPECT_FLOAT_EQ(this->target_x0_published_[1], 2.0f); +} + +TEST_F(LD6002BTest, FrameSplitAcrossLoopsIsJoined) { + const auto f = target_at(4.0f); + // Split inside the header and again inside the payload + this->feed_(std::vector(f.begin(), f.begin() + 3)); + this->feed_(std::vector(f.begin() + 3, f.begin() + 12)); + EXPECT_TRUE(this->target_x0_published_.empty()); + this->feed_(std::vector(f.begin() + 12, f.end())); + ASSERT_EQ(this->target_x0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->target_x0_published_[0], 4.0f); +} + +// The point_count sensor sizes the buffer at 4096; a longer payload is skipped by length, SOF bytes and all +TEST_F(LD6002BTest, OversizedFrameIsSkippedWithoutLosingNextFrame) { + this->feed_(concat(tf_frame(TYPE_REPORT_TARGET, std::vector(5000, ld600x::TF_SOF)), target_at(3.0f))); + ASSERT_EQ(this->target_x0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->target_x0_published_[0], 3.0f); +} + +TEST_F(LD6002BTest, UnknownTypeIsIgnored) { + this->uart_.tx.clear(); + this->feed_(tf_frame(0x0A99, {0x01, 0x02, 0x03, 0x04})); + this->feed_(tf_frame(0x0A99, {})); + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_(target_at(5.0f)); + ASSERT_EQ(this->target_x0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->target_x0_published_[0], 5.0f); +} + +TEST_F(LD6002BTest, ShortReportIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_TARGET, {0x01, 0x00})); + EXPECT_TRUE(this->target_count_published_.empty()); + EXPECT_FALSE(this->presence_.has_state()); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/ld600x_work_mode_test.cpp b/tests/components/ld6002b/ld600x_work_mode_test.cpp new file mode 100644 index 00000000000..39632fda232 --- /dev/null +++ b/tests/components/ld6002b/ld600x_work_mode_test.cpp @@ -0,0 +1,105 @@ +#ifdef USE_HOST +#include "common.h" + +namespace esphome::ld6002b::testing { + +namespace { + +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; +// A report type the base does not know, standing in for a model's own work mode report +static constexpr uint16_t TYPE_REPORT_MODEL_MODE = 0x0F00; + +// A model whose firmware reports the work mode itself, as the LD6004 does. The base derives no work +// mode of its own, so the LD6002B low power report and presence must leave this one alone. +class ModelReportedWorkMode : public ld600x::LD600XComponent { + public: + ModelReportedWorkMode() : LD600XComponent(LOG_STR("ld600x_test")) {} + + protected: + bool handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) override { + if (type != TYPE_REPORT_MODEL_MODE || len < 1) + return false; + this->work_mode_text_sensor_->publish_state(data[0] != 0 ? "tracking" : "idle"); + return true; + } +}; + +/// A hub with only the work mode text sensor and the low power switch, past its boot timeout. +template class WorkModeTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = std::filesystem::temp_directory_path() / "esphome_ld600x_work_mode_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_work_mode_text_sensor(&this->work_mode_); + this->sut_.setup(); + 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_; + T sut_; + text_sensor::TextSensor work_mode_; +}; + +using FallbackWorkModeTest = WorkModeTest; +using ModelWorkModeTest = WorkModeTest; + +} // namespace + +TEST_F(FallbackWorkModeTest, BootStartsTargetStreamAndQueriesLowPower) { + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID + 2, TYPE_CONTROL, u32_payload(CMD_GET_LOW_POWER))); +} + +TEST_F(ModelWorkModeTest, BootSkipsTargetStreamAndLowPowerQuery) { + // Only the point cloud stream is switched off; nothing is queried for the work mode. + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, 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(ModelWorkModeTest, LowPowerAndPresenceDoNotOverwriteModelText) { + this->feed_(tf_frame(TYPE_REPORT_MODEL_MODE, {0x01})); + EXPECT_EQ(this->work_mode_.get_state(), "tracking"); + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x01})); + EXPECT_EQ(this->work_mode_.get_state(), "tracking"); + this->feed_(target_report({{1.0f, 1.0f, 1.0f, 0, 4}})); + this->feed_(target_report({})); + EXPECT_EQ(this->work_mode_.get_state(), "tracking"); + this->feed_(tf_frame(TYPE_REPORT_MODEL_MODE, {0x00})); + EXPECT_EQ(this->work_mode_.get_state(), "idle"); +} + +TEST_F(ModelWorkModeTest, LowPowerReportAloneLeavesWorkModeUnset) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x01})); + EXPECT_FALSE(this->work_mode_.has_state()); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/number/ld6002b_number_test.cpp b/tests/components/ld6002b/number/ld6002b_number_test.cpp new file mode 100644 index 00000000000..3eec585cad6 --- /dev/null +++ b/tests/components/ld6002b/number/ld6002b_number_test.cpp @@ -0,0 +1,37 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6002b::testing { + +TEST_F(LD6002BTest, DelayReportPublishesHoldDelay) { + this->feed_(tf_frame(TYPE_REPORT_DELAY, u32_payload(300))); + EXPECT_FLOAT_EQ(this->hold_delay_.state, 300.0f); +} + +TEST_F(LD6002BTest, ZRangeReportPublishesLittleEndianFloats) { + std::vector p; + put_f32(p, -0.5f); + put_f32(p, 2.75f); + this->feed_(tf_frame(TYPE_REPORT_Z_RANGE, p)); + EXPECT_FLOAT_EQ(this->z_min_.state, -0.5f); + EXPECT_FLOAT_EQ(this->z_max_.state, 2.75f); +} + +TEST_F(LD6002BTest, ShortZRangeReportIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_Z_RANGE, {0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x00})); + EXPECT_FALSE(this->z_min_.has_state()); +} + +TEST_F(LD6002BTest, LowPowerSleepReportPublishes) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER_SLEEP, u32_payload(5000))); + EXPECT_FLOAT_EQ(this->low_power_sleep_.state, 5000.0f); +} + +TEST_F(LD6002BTest, ReportOutsideTheNumberRangeIsClamped) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER_SLEEP, u32_payload(70000))); + EXPECT_FLOAT_EQ(this->low_power_sleep_.state, 60000.0f); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/select/ld6002b_select_test.cpp b/tests/components/ld6002b/select/ld6002b_select_test.cpp new file mode 100644 index 00000000000..522dc1eec8d --- /dev/null +++ b/tests/components/ld6002b/select/ld6002b_select_test.cpp @@ -0,0 +1,31 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6002b::testing { + +TEST_F(LD6002BTest, SensitivityReportPublishesOption) { + this->feed_(tf_frame(TYPE_REPORT_SENSITIVITY, {0x02})); + EXPECT_EQ(this->sensitivity_.current_option(), "high"); +} + +TEST_F(LD6002BTest, OutOfRangeSensitivityIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_SENSITIVITY, {0x01})); + this->feed_(tf_frame(TYPE_REPORT_SENSITIVITY, {0x03})); + EXPECT_EQ(this->sensitivity_.current_option(), "medium"); +} + +TEST_F(LD6002BTest, TriggerSpeedReportPublishesOption) { + this->feed_(tf_frame(TYPE_REPORT_TRIGGER, {0x00})); + EXPECT_EQ(this->trigger_speed_.current_option(), "slow"); +} + +TEST_F(LD6002BTest, InstallationReportPublishesOption) { + this->feed_(tf_frame(TYPE_REPORT_INSTALLATION, {0x01})); + EXPECT_EQ(this->installation_.current_option(), "side"); + this->feed_(tf_frame(TYPE_REPORT_INSTALLATION, {0x02})); + EXPECT_EQ(this->installation_.current_option(), "side"); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/sensor/ld6002b_sensor_test.cpp b/tests/components/ld6002b/sensor/ld6002b_sensor_test.cpp new file mode 100644 index 00000000000..9ef34ce6859 --- /dev/null +++ b/tests/components/ld6002b/sensor/ld6002b_sensor_test.cpp @@ -0,0 +1,121 @@ +#ifdef USE_HOST +#include "../common.h" + +#include + +namespace esphome::ld6002b::testing { + +namespace { + +std::vector area_report(uint16_t type, uint8_t area, const std::array &bounds) { + std::vector p; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + for (float v : bounds) + put_f32(p, i == area ? v : 0.0f); + } + return tf_frame(type, p); +} + +} // namespace + +TEST_F(LD6002BTest, TargetReportPublishesLittleEndianFloats) { + this->feed_(target_report({{1.25f, -2.5f, 0.75f, -3, 7}})); + ASSERT_EQ(this->target_count_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->target_count_published_[0], 1.0f); + EXPECT_FLOAT_EQ(this->target_x_[0].state, 1.25f); + EXPECT_FLOAT_EQ(this->target_y_[0].state, -2.5f); + EXPECT_FLOAT_EQ(this->target_z_[0].state, 0.75f); + EXPECT_FLOAT_EQ(this->target_dop_[0].state, -3.0f); + EXPECT_FLOAT_EQ(this->target_cluster_[0].state, 7.0f); + EXPECT_FALSE(this->target_x_[1].has_state()); +} + +TEST_F(LD6002BTest, TargetCountIsPublishedOnChangeOnly) { + this->feed_(target_report({{1.0f, 1.0f, 1.0f, 0, 1}})); + this->feed_(target_report({{2.0f, 1.0f, 1.0f, 0, 1}})); + this->feed_(target_report({{2.0f, 1.0f, 1.0f, 0, 1}, {3.0f, 1.0f, 1.0f, 0, 2}})); + EXPECT_EQ(this->target_count_published_, (std::vector{1.0f, 2.0f})); + EXPECT_EQ(this->target_x0_published_, (std::vector{1.0f, 2.0f, 2.0f})); +} + +TEST_F(LD6002BTest, SlotsFollowClusterIdWhenModuleReorders) { + this->feed_(target_report({{1.0f, 0.0f, 0.0f, 0, 5}, {2.0f, 0.0f, 0.0f, 0, 9}})); + this->feed_(target_report({{2.5f, 0.0f, 0.0f, 0, 9}, {1.5f, 0.0f, 0.0f, 0, 5}})); + EXPECT_FLOAT_EQ(this->target_x_[0].state, 1.5f); + EXPECT_FLOAT_EQ(this->target_cluster_[0].state, 5.0f); + EXPECT_FLOAT_EQ(this->target_x_[1].state, 2.5f); + EXPECT_FLOAT_EQ(this->target_cluster_[1].state, 9.0f); +} + +TEST_F(LD6002BTest, TargetLeavingClearsItsSlot) { + this->feed_(target_report({{1.0f, 0.0f, 0.0f, 0, 5}, {2.0f, 0.0f, 0.0f, 0, 9}})); + this->feed_(target_report({{1.0f, 0.0f, 0.0f, 0, 5}})); + EXPECT_TRUE(std::isnan(this->target_x_[1].state)); + EXPECT_TRUE(std::isnan(this->target_cluster_[1].state)); + EXPECT_FLOAT_EQ(this->target_x_[0].state, 1.0f); +} + +TEST_F(LD6002BTest, MoreTargetsThanSlotsFillOnlyThree) { + std::vector four; + for (int32_t i = 0; i < 4; i++) + four.push_back({static_cast(i), 0.0f, 0.0f, 0, i + 1}); + this->feed_(target_report(four)); + EXPECT_FLOAT_EQ(this->target_count_published_.back(), 4.0f); + EXPECT_FLOAT_EQ(this->target_x_[2].state, 2.0f); +} + +TEST_F(LD6002BTest, TargetCountIsCappedAtWhatThePayloadHolds) { + // Claim 200 targets in a payload that carries one + std::vector p = u32_payload(200); + for (float v : {1.0f, 0.0f, 0.0f}) + put_f32(p, v); + put_u32(p, 0); + put_u32(p, 1); + this->feed_(tf_frame(TYPE_REPORT_TARGET, p)); + EXPECT_EQ(this->target_count_published_, (std::vector{1.0f})); +} + +TEST_F(LD6002BTest, PointCloudPublishesCount) { + std::vector p = u32_payload(12); + p.resize(4 + 12 * 16); + this->feed_(tf_frame(TYPE_REPORT_POINT_CLOUD, p)); + this->feed_(tf_frame(TYPE_REPORT_POINT_CLOUD, p)); + EXPECT_EQ(this->point_count_published_, (std::vector{12.0f})); +} + +// Larger than the 1024 byte default buffer, which a configured point_count raises to 4096 +TEST_F(LD6002BTest, LargePointCloudFitsTheRaisedBuffer) { + std::vector p = u32_payload(150); + p.resize(4 + 150 * 16); + this->feed_(tf_frame(TYPE_REPORT_POINT_CLOUD, p)); + EXPECT_EQ(this->point_count_published_, (std::vector{150.0f})); +} + +TEST_F(LD6002BTest, InterferenceAreaReportPublishesBounds) { + this->feed_(area_report(TYPE_REPORT_INTERFERENCE_AREAS, 0, {-1.5f, 1.5f, 0.25f, 3.0f, -0.5f, 2.75f})); + EXPECT_FLOAT_EQ(this->interference_0_[0].state, -1.5f); + EXPECT_FLOAT_EQ(this->interference_0_[1].state, 1.5f); + EXPECT_FLOAT_EQ(this->interference_0_[2].state, 0.25f); + EXPECT_FLOAT_EQ(this->interference_0_[3].state, 3.0f); + EXPECT_FLOAT_EQ(this->interference_0_[4].state, -0.5f); + EXPECT_FLOAT_EQ(this->interference_0_[5].state, 2.75f); + EXPECT_FALSE(this->detection_1_[0].has_state()); +} + +TEST_F(LD6002BTest, DetectionAreaReportPublishesTheRightArea) { + this->feed_(area_report(TYPE_REPORT_DETECTION_AREAS, 1, {-2.0f, 2.0f, 0.5f, 4.0f, 0.0f, 2.5f})); + EXPECT_FLOAT_EQ(this->detection_1_[0].state, -2.0f); + EXPECT_FLOAT_EQ(this->detection_1_[3].state, 4.0f); + EXPECT_FLOAT_EQ(this->detection_1_[5].state, 2.5f); + EXPECT_FALSE(this->interference_0_[0].has_state()); +} + +TEST_F(LD6002BTest, ShortAreaReportIsIgnored) { + auto p = std::vector(AREA_COUNT * 24 - 1, 0); + this->feed_(tf_frame(TYPE_REPORT_DETECTION_AREAS, p)); + EXPECT_FALSE(this->detection_1_[0].has_state()); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/switch/ld6002b_switch_test.cpp b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp new file mode 100644 index 00000000000..95ad89599c0 --- /dev/null +++ b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp @@ -0,0 +1,21 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6002b::testing { + +TEST_F(LD6002BTest, LowPowerReportPublishesSwitchState) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x01})); + EXPECT_TRUE(this->low_power_.state); + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x00})); + EXPECT_FALSE(this->low_power_.state); +} + +TEST_F(LD6002BTest, EmptyLowPowerReportIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x01})); + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {})); + EXPECT_TRUE(this->low_power_.state); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld6002b/text_sensor/ld6002b_text_sensor_test.cpp b/tests/components/ld6002b/text_sensor/ld6002b_text_sensor_test.cpp new file mode 100644 index 00000000000..a1f7b1e742b --- /dev/null +++ b/tests/components/ld6002b/text_sensor/ld6002b_text_sensor_test.cpp @@ -0,0 +1,37 @@ +#ifdef USE_HOST +#include "../common.h" + +namespace esphome::ld6002b::testing { + +TEST_F(LD6002BTest, VersionReportPublishesVersion) { + this->feed_(tf_frame(TYPE_QUERY_VERSION, {0x00, 0x01, 0x02, 0x03})); + EXPECT_EQ(this->ota_version_.get_state(), "1.2.3"); +} + +TEST_F(LD6002BTest, VersionReportNamesTheProject) { + this->feed_(tf_frame(TYPE_QUERY_VERSION, {0x02, 0x01, 0x00, 0x0A})); + EXPECT_EQ(this->ota_version_.get_state(), "p2 1.0.10"); +} + +TEST_F(LD6002BTest, ShortVersionReportIsIgnored) { + this->feed_(tf_frame(TYPE_QUERY_VERSION, {0x00, 0x01, 0x02})); + EXPECT_FALSE(this->ota_version_.has_state()); +} + +TEST_F(LD6002BTest, WorkModeReportPublishesMode) { + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {0x00})); + EXPECT_EQ(this->work_mode_.get_state(), "low_power"); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {0x01})); + EXPECT_EQ(this->work_mode_.get_state(), "normal"); +} + +TEST_F(LD6002BTest, WorkModeFallsBackToLowPowerReport) { + this->feed_(tf_frame(TYPE_REPORT_LOW_POWER, {0x01})); + EXPECT_EQ(this->work_mode_.get_state(), "low_power"); + this->feed_(target_report({{1.0f, 1.0f, 1.0f, 0, 4}})); + EXPECT_EQ(this->work_mode_.get_state(), "normal"); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST diff --git a/tests/components/ld600x/__init__.py b/tests/components/ld600x/__init__.py new file mode 100644 index 00000000000..6f046073358 --- /dev/null +++ b/tests/components/ld600x/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # The hub components size these through cg.slot_counter; the gtests build + # the base without a hub, so give it the largest model dimensions so that + # every size-gated branch of the base is compiled here. + cg.add_define("LD600X_MAX_TARGETS", 3) + cg.add_define("LD600X_AREA_KINDS", 3) + + manifest.to_code = to_code_testing diff --git a/tests/components/ld600x/ld600x_frame_test.cpp b/tests/components/ld600x/ld600x_frame_test.cpp new file mode 100644 index 00000000000..7720ccfa312 --- /dev/null +++ b/tests/components/ld600x/ld600x_frame_test.cpp @@ -0,0 +1,258 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/ld600x/ld600x_frame.h" + +namespace esphome::ld600x::testing { + +namespace { + +uint8_t checksum(const std::vector &bytes) { + return static_cast(~xor_bytes(bytes.data(), bytes.size())); +} + +// Independent of encode_frame, so the parser tests do not rely on the encoder. +std::vector make_frame(uint16_t id, uint16_t type, const std::vector &payload) { + std::vector frame = {TF_SOF, + static_cast(id >> 8), + static_cast(id), + static_cast(payload.size() >> 8), + static_cast(payload.size()), + static_cast(type >> 8), + static_cast(type)}; + frame.push_back(checksum(frame)); + frame.insert(frame.end(), payload.begin(), payload.end()); + if (!payload.empty()) { + frame.push_back(checksum(payload)); + } + return frame; +} + +// SOF, big-endian ID, length, type, header checksum, payload, data checksum. +const std::vector SAMPLE_PAYLOAD = {0x10, 0x01, 0xFE}; +constexpr std::array SAMPLE_FRAME = {0x01, 0x12, 0x34, 0x00, 0x03, 0x56, + 0x78, 0xF5, 0x10, 0x01, 0xFE, 0x10}; + +void expect_frame(const FrameParser &parser, uint16_t id, uint16_t type, const std::vector &payload) { + EXPECT_EQ(parser.id(), id); + EXPECT_EQ(parser.type(), type); + ASSERT_EQ(parser.length(), payload.size()); + if (payload.empty()) { + EXPECT_EQ(parser.data(), nullptr); + } else { + ASSERT_NE(parser.data(), nullptr); + EXPECT_EQ(std::vector(parser.data(), parser.data() + parser.length()), payload); + } +} + +void feed_valid_frame(FrameParser &parser, const std::vector &frame, uint16_t id, uint16_t type, + const std::vector &payload) { + size_t completions = 0; + for (size_t i = 0; i < frame.size(); i++) { + const bool complete = parser.feed(frame[i]); + EXPECT_EQ(complete, i == frame.size() - 1) << "byte " << i; + if (complete) { + completions++; + expect_frame(parser, id, type, payload); + } + } + EXPECT_EQ(completions, 1u); +} + +void feed_valid_frame(FrameParser &parser, uint16_t id, uint16_t type, const std::vector &payload) { + feed_valid_frame(parser, make_frame(id, type, payload), id, type, payload); +} + +void feed_rejected(FrameParser &parser, const std::vector &bytes) { + for (uint8_t byte : bytes) { + EXPECT_FALSE(parser.feed(byte)); + } +} + +const std::vector NEXT_PAYLOAD = {0x42, 0x81}; + +} // namespace + +class Ld600xFrameParser : public ::testing::Test { + protected: + void SetUp() override { this->parser_.init(this->buffer_.data(), this->buffer_.size()); } + + // Re-initialise on a slice of the buffer with guard bytes filled on both sides. + void init_guarded_(uint16_t capacity) { + this->guarded_capacity_ = capacity; + this->buffer_.fill(0xA5); + this->parser_.init(this->buffer_.data() + 1, capacity); + } + + void expect_guards_intact_() const { + EXPECT_EQ(this->buffer_.front(), 0xA5); + EXPECT_EQ(this->buffer_[this->guarded_capacity_ + 1], 0xA5); + } + + std::array buffer_{}; + FrameParser parser_; + uint16_t guarded_capacity_{0}; +}; + +TEST_F(Ld600xFrameParser, CompleteFrameByteByByte) { + const std::vector frame = make_frame(0x1234, 0x5678, SAMPLE_PAYLOAD); + ASSERT_EQ(frame, (std::vector(SAMPLE_FRAME.begin(), SAMPLE_FRAME.end()))); + feed_valid_frame(this->parser_, frame, 0x1234, 0x5678, SAMPLE_PAYLOAD); +} + +TEST_F(Ld600xFrameParser, EmptyPayloadCompletesAtHeaderChecksum) { + ASSERT_EQ(make_frame(0x1234, 0x5678, {}).size(), 8u); + feed_valid_frame(this->parser_, 0x1234, 0x5678, {}); + feed_valid_frame(this->parser_, 0x1234, 0x5678, {}); +} + +TEST_F(Ld600xFrameParser, HeaderNeverTouchesTheBuffer) { + // A buffer smaller than the header, guarded on both sides. + this->init_guarded_(2); + const std::array untouched = this->buffer_; + feed_rejected(this->parser_, make_frame(0x1234, 0x5678, {0x10, 0x20, 0x30})); + EXPECT_EQ(this->buffer_, untouched); + feed_valid_frame(this->parser_, 0x1234, 0x5678, NEXT_PAYLOAD); + this->expect_guards_intact_(); +} + +TEST(Ld600xFrameParserUninitialised, FeedBeforeInitSkipsPayloads) { + FrameParser parser; + feed_rejected(parser, make_frame(0x1234, 0x5678, {0x01, 0x02})); + feed_valid_frame(parser, 0x1234, 0x5678, {}); +} + +enum class Damage : uint8_t { DAMAGE_HEADER_CHECKSUM, DAMAGE_DATA_CHECKSUM }; + +class Ld600xFrameDamage : public Ld600xFrameParser, public ::testing::WithParamInterface { + protected: + // An empty frame ends with its header checksum, a non-empty one with its data checksum. + std::vector damaged_frame_() const { + return make_frame( + 0x1234, 0x5678, + GetParam() == Damage::DAMAGE_HEADER_CHECKSUM ? std::vector{} : std::vector{1, 2, 3}); + } +}; + +TEST_P(Ld600xFrameDamage, ChecksumMismatchDropsFrameAndResynchronises) { + std::vector corrupt = this->damaged_frame_(); + corrupt.back() ^= 0x80; + feed_rejected(this->parser_, corrupt); + feed_valid_frame(this->parser_, 0xABCD, 0x9876, NEXT_PAYLOAD); +} + +TEST_P(Ld600xFrameDamage, DroppedChecksumKeepsNextFrame) { + std::vector truncated = this->damaged_frame_(); + truncated.pop_back(); // The checksum byte never arrives. + const std::vector next = make_frame(0xABCD, 0x9876, NEXT_PAYLOAD); + feed_rejected(this->parser_, truncated); + // The next SOF lands where the checksum was expected: it fails the check, then starts the next header. + EXPECT_FALSE(this->parser_.feed(next[0])); + feed_valid_frame(this->parser_, {next.begin() + 1, next.end()}, 0xABCD, 0x9876, NEXT_PAYLOAD); +} + +INSTANTIATE_TEST_SUITE_P(Checksums, Ld600xFrameDamage, + ::testing::Values(Damage::DAMAGE_HEADER_CHECKSUM, Damage::DAMAGE_DATA_CHECKSUM)); + +TEST_F(Ld600xFrameParser, OversizedPayloadAndChecksumAreDiscardedWithoutBufferWrites) { + // A complete frame inside the discarded payload must not be parsed. + std::vector payload = make_frame(0x1234, 0x5678, {}); + payload.push_back(checksum(payload) ^ 0x01); + const std::vector oversized = make_frame(0xABCD, 0x9876, payload); + ASSERT_EQ(oversized.back(), 0x01); // The discarded checksum must not start a frame either. + this->init_guarded_(6); + feed_rejected(this->parser_, {oversized.begin(), oversized.begin() + 8}); + this->expect_guards_intact_(); + const std::array after_header = this->buffer_; + for (size_t i = 8; i < oversized.size(); i++) { + EXPECT_FALSE(this->parser_.feed(oversized[i])); + EXPECT_EQ(this->buffer_, after_header); + } + feed_valid_frame(this->parser_, 0x1234, 0x5678, NEXT_PAYLOAD); + this->expect_guards_intact_(); +} + +TEST_F(Ld600xFrameParser, MaximumLengthOversizedFrameIsSkippedExactly) { + EXPECT_EQ(this->parser_.capacity(), this->buffer_.size()); + std::vector header = {TF_SOF, 0x12, 0x34, 0xFF, 0xFF, 0x56, 0x78}; + header.push_back(checksum(header)); + feed_rejected(this->parser_, header); + // Payload and checksum made of SOF bytes: any miscount would start a header from one of them. + for (uint32_t i = 0; i < 0x10000; i++) { + ASSERT_FALSE(this->parser_.feed(TF_SOF)) << "byte " << i; + } + feed_valid_frame(this->parser_, 0xABCD, 0x9876, NEXT_PAYLOAD); +} + +TEST_F(Ld600xFrameParser, GarbageBetweenFramesIsIgnored) { + feed_valid_frame(this->parser_, 0x1234, 0x5678, {0x42}); + feed_rejected(this->parser_, {0x00, 0xFF, 0xA5, 0x5A, 0x02, 0x80}); + feed_valid_frame(this->parser_, 0xABCD, 0x9876, {0x81}); +} + +TEST(Ld600xEncodeFrame, PayloadLayoutAndRoundTrip) { + std::array encoded{}; + ASSERT_EQ(encode_frame(0x1234, 0x5678, SAMPLE_PAYLOAD.data(), SAMPLE_PAYLOAD.size(), encoded.data()), + SAMPLE_FRAME.size()); + EXPECT_EQ(encoded, SAMPLE_FRAME); + std::array buffer{}; + FrameParser parser; + parser.init(buffer.data(), buffer.size()); + feed_valid_frame(parser, {encoded.begin(), encoded.end()}, 0x1234, 0x5678, SAMPLE_PAYLOAD); +} + +TEST(Ld600xEncodeFrame, EmptyPayloadLayoutAndRoundTrip) { + // The trailing guard byte must not be written. + const std::array expected = {0x01, 0x12, 0x34, 0x00, 0x00, 0x56, 0x78, 0xF6, 0xA5}; + std::array encoded{}; + encoded.fill(0xA5); + ASSERT_EQ(encode_frame(0x1234, 0x5678, nullptr, 0, encoded.data()), 8u); + EXPECT_EQ(encoded, expected); + std::array buffer{}; + FrameParser parser; + parser.init(buffer.data(), buffer.size()); + feed_valid_frame(parser, {encoded.begin(), encoded.begin() + 8}, 0x1234, 0x5678, {}); +} + +TEST(Ld600xEncodeFrame, SixteenBitLengthMatchesReference) { + const std::vector payload(300, 0x5A); + std::vector encoded(TF_FRAME_OVERHEAD + payload.size()); + ASSERT_EQ(encode_frame(0x8001, 0x0A08, payload.data(), payload.size(), encoded.data()), encoded.size()); + EXPECT_EQ(encoded, make_frame(0x8001, 0x0A08, payload)); +} + +TEST(Ld600xFrameId, SetsTopBitAndWrapsLowBits) { + uint16_t counter = 0; + EXPECT_EQ(next_frame_id(counter), 0x8000); + EXPECT_EQ(next_frame_id(counter), 0x8001); + counter = 0x7FFF; + EXPECT_EQ(next_frame_id(counter), 0xFFFF); + EXPECT_EQ(next_frame_id(counter), 0x8000); + counter = 0xFFFF; + EXPECT_EQ(next_frame_id(counter), 0xFFFF); + EXPECT_EQ(counter, 0); +} + +TEST(Ld600xByteHelpers, UnsignedInt32) { + const std::array bytes = {0x12, 0x34, 0x56, 0xF8}; + std::array written{}; + write_u32_le(written.data(), 0xF8563412u); + EXPECT_EQ(written, bytes); + write_u32_le(written.data(), static_cast(-2)); + EXPECT_EQ(written, (std::array{0xFE, 0xFF, 0xFF, 0xFF})); +} + +TEST(Ld600xByteHelpers, FloatLayoutAndRoundTrip) { + const std::array bytes = {0x00, 0x00, 0x48, 0xC1}; + EXPECT_FLOAT_EQ(read_f32_le(bytes.data()), -12.5f); + std::array written{}; + write_f32_le(written.data(), -12.5f); + EXPECT_EQ(written, bytes); + EXPECT_FLOAT_EQ(read_f32_le(written.data()), -12.5f); +} + +} // namespace esphome::ld600x::testing diff --git a/tests/components/mhz19/__init__.py b/tests/components/mhz19/__init__.py new file mode 100644 index 00000000000..b31ba62b0af --- /dev/null +++ b/tests/components/mhz19/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the sensor platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["uart", "sensor"] diff --git a/tests/components/mhz19/mhz19_warmup_test.cpp b/tests/components/mhz19/mhz19_warmup_test.cpp new file mode 100644 index 00000000000..653a8e5086f --- /dev/null +++ b/tests/components/mhz19/mhz19_warmup_test.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include + +#include "esphome/components/mhz19/mhz19.h" +#include "esphome/core/application.h" + +namespace esphome::mhz19 { +uint8_t mhz19_checksum(const uint8_t *command); +} // namespace esphome::mhz19 + +namespace esphome::mhz19::testing { +namespace { + +// Answers every GET_PPM request with a valid 400 ppm, 25 °C frame. +class FakeUART final : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + if (len >= 3 && data[0] == 0xFF && data[2] == 0x86) { + static constexpr uint8_t FRAME[] = {0xFF, 0x86, 0x01, 0x90, 25 + 40, 0x00, 0x00, 0x00}; + this->rx.assign(std::begin(FRAME), std::end(FRAME)); + this->rx.push_back(mhz19_checksum(FRAME)); + } + } + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + for (size_t i = 0; i < len; i++) { + data[i] = this->rx.front(); + this->rx.pop_front(); + } + return true; + } + bool peek_byte(uint8_t *data) override { return false; } + size_t available() override { return 0; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + std::deque rx; +}; + +class MHZ19WarmupTest : public ::testing::Test { + protected: + void SetUp() override { + this->mhz19_.set_uart_parent(&this->uart_); + this->mhz19_.set_co2_sensor(&this->co2_); + this->mhz19_.set_warmup_seconds(75); + } + + bool update_at_(uint32_t now) { + LoopBlockingGuard{nullptr, nullptr, now}; // sets the loop's cached time + this->co2_.state = NAN; + this->mhz19_.update(); + return !std::isnan(this->co2_.state); + } + + FakeUART uart_; + sensor::Sensor co2_; + MHZ19Component mhz19_; +}; + +} // namespace + +TEST_F(MHZ19WarmupTest, SkipsReadingsDuringWarmup) { + EXPECT_FALSE(this->update_at_(1000)); + EXPECT_FALSE(this->update_at_(74999)); + EXPECT_TRUE(this->update_at_(75000)); + EXPECT_FLOAT_EQ(this->co2_.state, 400.0f); +} + +// About 49.7 days after boot the uptime counter wraps to 0; readings must not stop for another warm-up. +TEST_F(MHZ19WarmupTest, KeepsReadingAfterUptimeWraps) { + EXPECT_TRUE(this->update_at_(4000000000U)); + EXPECT_TRUE(this->update_at_(500)); + EXPECT_TRUE(this->update_at_(74999)); + EXPECT_FLOAT_EQ(this->co2_.state, 400.0f); +} + +} // namespace esphome::mhz19::testing diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml index ce01908be73..86e84b20c8e 100644 --- a/tests/components/network/test.nrf52-adafruit.yaml +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -4,3 +4,9 @@ network: openthread: tlv: 0E080000000000010000 + +esphome: + on_boot: + - lambda: |- + char buf[network::IP_ADDRESS_BUFFER_SIZE]; + ESP_LOGD("network", "Parsed: %s", network::IPAddress("fd00::1").str_to(buf)); diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml index b640232d5be..67105080638 100644 --- a/tests/components/network/test.nrf52-mcumgr.yaml +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -4,3 +4,9 @@ network: openthread: tlv: 0E080000000000010000 + +esphome: + on_boot: + - lambda: |- + char buf[network::IP_ADDRESS_BUFFER_SIZE]; + ESP_LOGD("network", "Parsed: %s", network::IPAddress("fd00::1").str_to(buf)); diff --git a/tests/components/ota/test-udp.nrf52-mcumgr.yaml b/tests/components/ota/test-udp.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..34586d45feb --- /dev/null +++ b/tests/components/ota/test-udp.nrf52-mcumgr.yaml @@ -0,0 +1,29 @@ +# SMP-over-UDP OTA transport (Thread / IPv6). Kept separate from the BLE+UART +# test because OpenThread (802.15.4) and the BLE SoftDevice are not run together. +openthread: + tlv: 0E080000000000010000 + +ota: + - platform: zephyr_mcumgr + transport: + udp: true + on_begin: + then: + - logger.log: "OTA start" + on_progress: + then: + - logger.log: + format: "OTA progress %0.1f%%" + args: ["x"] + on_end: + then: + - logger.log: "OTA end" + on_error: + then: + - logger.log: + format: "OTA update error %d" + args: ["x"] + on_state_change: + then: + lambda: >- + ESP_LOGD("ota", "State %d", state); diff --git a/tests/components/output/__init__.py b/tests/components/output/__init__.py index 31e0b155a24..bb602212f95 100644 --- a/tests/components/output/__init__.py +++ b/tests/components/output/__init__.py @@ -1,9 +1,17 @@ import esphome.codegen as cg +from esphome.loader import FileResource from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: + # The output button helper is under test; its button platform is not in this build + manifest.dependencies = manifest.dependencies + ["button"] + manifest.add_resources( + FileResource("esphome.components.output", "button/output_button.h"), + FileResource("esphome.components.output", "button/output_button.cpp"), + ) + # Builds the min/max power scaling so the Apply output tests cover it async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") diff --git a/tests/components/output/output_button_test.cpp b/tests/components/output/output_button_test.cpp new file mode 100644 index 00000000000..4bd14d55d0c --- /dev/null +++ b/tests/components/output/output_button_test.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include "esphome/components/button/button.h" +#include "esphome/components/output/binary_output.h" +#include "esphome/components/output/button/output_button.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::output::testing { + +static std::vector writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_write(BinaryOutput * /*output*/, uint16_t /*index*/, bool state) { writes.push_back(state); } + +static void on_press(button::Button * /*entity*/) {} + +static void run_scheduler_after(uint32_t ms) { + delay(ms); + App.scheduler.call(millis()); +} + +TEST(OutputButton, TurnsOnAndOffAfterTheDuration) { + writes.clear(); + ApplyBinaryOutput out(0); + button::ApplyButton button; + press_output_button(&button, &out, 50); + EXPECT_EQ(writes, (std::vector{true})); + run_scheduler_after(100); + EXPECT_EQ(writes, (std::vector{true, false})); +} + +TEST(OutputButton, SecondPressRestartsTheReset) { + writes.clear(); + ApplyBinaryOutput out(0); + button::ApplyButton button; + press_output_button(&button, &out, 150); + run_scheduler_after(100); + press_output_button(&button, &out, 150); + // Past the first press's reset, inside the second's + run_scheduler_after(100); + EXPECT_EQ(writes, (std::vector{true, true})); + run_scheduler_after(100); + EXPECT_EQ(writes, (std::vector{true, true, false})); +} + +TEST(OutputButton, ButtonsResetIndependently) { + writes.clear(); + ApplyBinaryOutput out(0); + button::ApplyButton first; + button::ApplyButton second; + press_output_button(&first, &out, 50); + press_output_button(&second, &out, 50); + run_scheduler_after(100); + EXPECT_EQ(writes, (std::vector{true, true, false, false})); +} + +} // namespace esphome::output::testing diff --git a/tests/components/pm1006/common.h b/tests/components/pm1006/common.h new file mode 100644 index 00000000000..99e85d7bf82 --- /dev/null +++ b/tests/components/pm1006/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::pm1006::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::pm1006::testing diff --git a/tests/components/pm1006/sensor/pm1006_test.cpp b/tests/components/pm1006/sensor/pm1006_test.cpp new file mode 100644 index 00000000000..06006d7ff4e --- /dev/null +++ b/tests/components/pm1006/sensor/pm1006_test.cpp @@ -0,0 +1,104 @@ +#include + +#include +#include + +#include "esphome/components/pm1006/pm1006.h" +#include "esphome/components/sensor/sensor.h" +#include "../common.h" + +namespace esphome::pm1006::testing { + +namespace { + +std::vector frame(uint16_t pm25) { + std::vector f{0x16, 0x11, 0x0B, 0x00, 0x00, static_cast(pm25 >> 8), static_cast(pm25)}; + f.resize(19, 0x00); + uint8_t sum = 0; + for (uint8_t b : f) + sum += b; + f.push_back(static_cast(-sum)); + return f; +} + +class PM1006Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_pm_2_5_sensor(&this->pm_2_5_); + this->pm_2_5_.add_on_state_callback([this](float state) { this->published_.push_back(state); }); + } + + void feed_(const std::vector &bytes) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + this->sut_.loop(); + } + + MockUARTComponent uart_; + PM1006Component sut_; + sensor::Sensor pm_2_5_; + std::vector published_; +}; + +} // namespace + +TEST_F(PM1006Test, GoodFramePublishes) { + this->feed_(frame(0x0123)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 0x0123); +} + +TEST_F(PM1006Test, BadChecksumIsDropped) { + auto bad = frame(42); + bad[19] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(43)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 43); +} + +TEST_F(PM1006Test, GarbageBeforeHeaderIsSkipped) { + this->feed_({0x00, 0xFF, 0x11, 0x0B}); + this->feed_(frame(17)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 17); +} + +TEST_F(PM1006Test, BrokenHeaderIsSkipped) { + this->feed_({0x16, 0x11, 0x00}); + this->feed_(frame(18)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 18); +} + +TEST_F(PM1006Test, SplitReadsAreJoined) { + auto f = frame(500); + this->feed_(std::vector(f.begin(), f.begin() + 2)); + this->feed_(std::vector(f.begin() + 2, f.begin() + 11)); + EXPECT_TRUE(this->published_.empty()); + this->feed_(std::vector(f.begin() + 11, f.end())); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 500); +} + +TEST_F(PM1006Test, BackToBackFrames) { + auto a = frame(1), b = frame(2); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_FLOAT_EQ(this->published_[1], 2); +} + +// The byte that breaks a header is dropped, not checked as the start of a new header. +TEST_F(PM1006Test, RepeatedFirstHeaderByteLosesFrame) { + auto f = frame(77); + f.insert(f.begin(), 0x16); + this->feed_(f); + EXPECT_TRUE(this->published_.empty()); + this->feed_(frame(78)); + ASSERT_EQ(this->published_.size(), 1u); + EXPECT_FLOAT_EQ(this->published_[0], 78); +} + +} // namespace esphome::pm1006::testing diff --git a/tests/components/sds011/common.h b/tests/components/sds011/common.h new file mode 100644 index 00000000000..c9e83d9fe3e --- /dev/null +++ b/tests/components/sds011/common.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::sds011::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + 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 +}; + +} // namespace esphome::sds011::testing diff --git a/tests/components/sds011/sensor/sds011_test.cpp b/tests/components/sds011/sensor/sds011_test.cpp new file mode 100644 index 00000000000..09dc5c9f1ad --- /dev/null +++ b/tests/components/sds011/sensor/sds011_test.cpp @@ -0,0 +1,155 @@ +#include + +#include +#include + +#include "esphome/components/sds011/sds011.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/application.h" +#include "../common.h" + +namespace esphome::sds011::testing { + +namespace { + +// PM values in tenths of a µg/m³ +std::vector frame(uint16_t pm25, uint16_t pm10) { + std::vector f{0xAA, + 0xC0, + static_cast(pm25), + static_cast(pm25 >> 8), + static_cast(pm10), + static_cast(pm10 >> 8), + 0x12, + 0x34}; + uint8_t sum = 0; + for (size_t i = 2; i < 8; i++) + sum += f[i]; + f.push_back(sum); + f.push_back(0xAB); + return f; +} + +class SDS011Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_rx_mode_only(true); + this->sut_.set_pm_2_5_sensor(&this->pm_2_5_); + this->sut_.set_pm_10_0_sensor(&this->pm_10_0_); + this->pm_2_5_.add_on_state_callback([this](float state) { this->pm_2_5_published_.push_back(state); }); + this->pm_10_0_.add_on_state_callback([this](float state) { this->pm_10_0_published_.push_back(state); }); + } + + void feed_(const std::vector &bytes, uint32_t now = 0) { + this->uart_.rx.insert(this->uart_.rx.end(), bytes.begin(), bytes.end()); + LoopBlockingGuard{nullptr, nullptr, now}; + this->sut_.loop(); + } + + MockUARTComponent uart_; + SDS011Component sut_; + sensor::Sensor pm_2_5_; + sensor::Sensor pm_10_0_; + std::vector pm_2_5_published_; + std::vector pm_10_0_published_; +}; + +} // namespace + +TEST_F(SDS011Test, GoodFramePublishes) { + this->feed_(frame(123, 456)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + ASSERT_EQ(this->pm_10_0_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 12.3f); + EXPECT_FLOAT_EQ(this->pm_10_0_published_[0], 45.6f); +} + +TEST_F(SDS011Test, AllZeroValuesAreNotPublished) { + this->feed_(frame(0, 0)); + EXPECT_TRUE(this->pm_2_5_published_.empty()); +} + +TEST_F(SDS011Test, BadChecksumIsDropped) { + auto bad = frame(123, 456); + bad[8] ^= 1; + this->feed_(bad); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(10, 20)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 1.0f); +} + +TEST_F(SDS011Test, BadTailIsDropped) { + auto bad = frame(123, 456); + bad[9] = 0x00; + this->feed_(bad); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(10, 20)); + EXPECT_EQ(this->pm_2_5_published_.size(), 1u); +} + +TEST_F(SDS011Test, OtherCommandIsIgnored) { + auto reply = frame(123, 456); + reply[1] = 0xC5; + this->feed_(reply); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(10, 20)); + EXPECT_EQ(this->pm_2_5_published_.size(), 1u); +} + +TEST_F(SDS011Test, GarbageBeforeHeaderIsSkipped) { + this->feed_({0x01, 0xC0, 0xAB}); + this->feed_(frame(50, 60)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_10_0_published_[0], 6.0f); +} + +TEST_F(SDS011Test, SplitReadsAreJoined) { + auto f = frame(70, 80); + this->feed_(std::vector(f.begin(), f.begin() + 1), 0); + this->feed_(std::vector(f.begin() + 1, f.begin() + 6), 100); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(std::vector(f.begin() + 6, f.end()), 200); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 7.0f); +} + +// A frame idle for 500 ms or more is dropped, and its tail is not mistaken for a frame +TEST_F(SDS011Test, GapDropsPartialFrame) { + auto f = frame(70, 80); + this->feed_(std::vector(f.begin(), f.begin() + 5), 1000); + this->feed_(std::vector(f.begin() + 5, f.end()), 1500); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(90, 100), 1600); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 9.0f); +} + +TEST_F(SDS011Test, ShortGapKeepsPartialFrame) { + auto f = frame(70, 80); + this->feed_(std::vector(f.begin(), f.begin() + 5), 1000); + this->feed_(std::vector(f.begin() + 5, f.end()), 1499); + EXPECT_EQ(this->pm_2_5_published_.size(), 1u); +} + +TEST_F(SDS011Test, BackToBackFrames) { + auto a = frame(1, 2), b = frame(3, 4); + a.insert(a.end(), b.begin(), b.end()); + this->feed_(a); + ASSERT_EQ(this->pm_2_5_published_.size(), 2u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[1], 0.3f); +} + +// The byte that breaks a header is dropped, not checked as the start of a new header. +TEST_F(SDS011Test, RepeatedFirstHeaderByteLosesFrame) { + auto f = frame(70, 80); + f.insert(f.begin(), 0xAA); + this->feed_(f); + EXPECT_TRUE(this->pm_2_5_published_.empty()); + this->feed_(frame(90, 100)); + ASSERT_EQ(this->pm_2_5_published_.size(), 1u); + EXPECT_FLOAT_EQ(this->pm_2_5_published_[0], 9.0f); +} + +} // namespace esphome::sds011::testing diff --git a/tests/components/sensor/source_value_test.cpp b/tests/components/sensor/source_value_test.cpp new file mode 100644 index 00000000000..b905952e301 --- /dev/null +++ b/tests/components/sensor/source_value_test.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "esphome/components/sensor/sensor.h" + +namespace esphome::sensor::testing { + +TEST(SourceValue, NullSensorIsNan) { EXPECT_TRUE(std::isnan(source_value(nullptr, -40.0f, 85.0f))); } + +TEST(SourceValue, NoStateYetIsNan) { + Sensor source; + EXPECT_TRUE(std::isnan(source_value(&source, -40.0f, 85.0f))); +} + +TEST(SourceValue, NanStateIsNan) { + Sensor source; + source.publish_state(NAN); + EXPECT_TRUE(std::isnan(source_value(&source, -40.0f, 85.0f))); +} + +TEST(SourceValue, InRangeStateIsReturned) { + Sensor source; + source.publish_state(21.5f); + EXPECT_EQ(source_value(&source, -40.0f, 85.0f), 21.5f); +} + +TEST(SourceValue, BoundsAreIncluded) { + Sensor source; + source.publish_state(-40.0f); + EXPECT_EQ(source_value(&source, -40.0f, 85.0f), -40.0f); + source.publish_state(85.0f); + EXPECT_EQ(source_value(&source, -40.0f, 85.0f), 85.0f); +} + +TEST(SourceValue, OutOfRangeStateIsNan) { + Sensor source; + source.publish_state(-40.1f); + EXPECT_TRUE(std::isnan(source_value(&source, -40.0f, 85.0f))); + source.publish_state(85.1f); + EXPECT_TRUE(std::isnan(source_value(&source, -40.0f, 85.0f))); + source.publish_state(INFINITY); + EXPECT_TRUE(std::isnan(source_value(&source, -40.0f, 85.0f))); +} + +TEST(SourceValue, LatestStateIsRead) { + Sensor source; + source.publish_state(101325.0f); + EXPECT_TRUE(std::isnan(source_value(&source, 700.0f, 1200.0f))); + source.publish_state(1013.0f); + EXPECT_EQ(source_value(&source, 700.0f, 1200.0f), 1013.0f); +} + +} // namespace esphome::sensor::testing diff --git a/tests/components/tee501/__init__.py b/tests/components/tee501/__init__.py new file mode 100644 index 00000000000..8cb623e51b6 --- /dev/null +++ b/tests/components/tee501/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the sensor platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/tee501/tee501_i2c_error_test.cpp b/tests/components/tee501/tee501_i2c_error_test.cpp new file mode 100644 index 00000000000..fbec218e50e --- /dev/null +++ b/tests/components/tee501/tee501_i2c_error_test.cpp @@ -0,0 +1,89 @@ +#include + +#include + +#include "esphome/components/tee501/tee501.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tee501::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x40; + +// Always fills the read buffer with a valid answer, so a transfer reported as failed still carries a +// frame that passes the CRC check. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + // 24.00 °C, 50.00 %RH + static constexpr uint8_t MEASUREMENT[2][2] = {{0x09, 0x60}, {0x13, 0x88}}; + for (size_t i = 0; i + 3 <= out_size; i += 3) { + const uint8_t *word = MEASUREMENT[i / 3 % 2]; + out[i] = word[0]; + out[i + 1] = word[1]; + out[i + 2] = crc8(word, 2, 0xFF, 0x31, true); + } + if (out_size == 9) // identification: CRC over all 8 bytes + out[8] = crc8(out, 8, 0xFF, 0x31, true); + if (size != 0) + return this->fail_writes ? i2c::ERROR_NOT_ACKNOWLEDGED : i2c::ERROR_OK; + return this->fail_reads ? i2c::ERROR_TIMEOUT : i2c::ERROR_OK; + } + + bool fail_writes{false}; + bool fail_reads{false}; +}; + +class TEE501I2CErrorTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.set_i2c_bus(&this->bus_); + this->sensor_.set_i2c_address(ADDRESS); + } + + // update() sends the measure command and reads the answer 50 ms later. + void measure_() { + this->temperature_.state = NAN; + this->sensor_.update(); + delay(60); // NOLINT + App.scheduler.call(millis()); + } + + FakeBus bus_; + TEE501Component sensor_; + sensor::Sensor &temperature_{this->sensor_}; +}; + +} // namespace + +TEST_F(TEE501I2CErrorTest, MeasuresWhenTheBusWorks) { + this->measure_(); + EXPECT_FLOAT_EQ(this->temperature_.state, 24.0f); + EXPECT_FALSE(this->sensor_.status_has_warning()); +} + +TEST_F(TEE501I2CErrorTest, SetupFailsWhenIdentificationReadFails) { + this->bus_.fail_writes = true; + this->sensor_.setup(); + EXPECT_TRUE(this->sensor_.is_failed()); +} + +TEST_F(TEE501I2CErrorTest, FailedCommandSkipsTheReading) { + this->bus_.fail_writes = true; + this->measure_(); + EXPECT_TRUE(std::isnan(this->temperature_.state)); + EXPECT_TRUE(this->sensor_.status_has_warning()); +} + +TEST_F(TEE501I2CErrorTest, FailedReadSkipsTheReading) { + this->bus_.fail_reads = true; + this->measure_(); + EXPECT_TRUE(std::isnan(this->temperature_.state)); + EXPECT_TRUE(this->sensor_.status_has_warning()); +} + +} // namespace esphome::tee501::testing diff --git a/tests/components/tee501/tee501_setup_test.cpp b/tests/components/tee501/tee501_setup_test.cpp new file mode 100644 index 00000000000..dd35336bd6e --- /dev/null +++ b/tests/components/tee501/tee501_setup_test.cpp @@ -0,0 +1,44 @@ +#include + +#include "esphome/components/tee501/tee501.h" +#include "esphome/core/helpers.h" + +namespace esphome::tee501::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x48; + +// Answers the read identification command (0x7029) and NACKs a read sent without a command. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + this->transactions++; + if (size != 2 || data[0] != 0x70 || data[1] != 0x29 || out_size != 9) + return i2c::ERROR_NOT_ACKNOWLEDGED; + static constexpr uint8_t SERIAL[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + for (size_t i = 0; i < 8; i++) + out[i] = SERIAL[i]; + out[8] = crc8(SERIAL, 8, 0xFF, 0x31, true); + return i2c::ERROR_OK; + } + + unsigned transactions{0}; +}; + +} // namespace + +TEST(TEE501SetupTest, ReadsIdentificationInOneTransaction) { + FakeBus bus; + TEE501Component tee501; + tee501.set_i2c_bus(&bus); + tee501.set_i2c_address(ADDRESS); + + tee501.setup(); + + EXPECT_EQ(bus.transactions, 1u); + EXPECT_FALSE(tee501.is_failed()); +} + +} // namespace esphome::tee501::testing diff --git a/tests/components/tee501/tee501_temperature_test.cpp b/tests/components/tee501/tee501_temperature_test.cpp new file mode 100644 index 00000000000..a952bc16451 --- /dev/null +++ b/tests/components/tee501/tee501_temperature_test.cpp @@ -0,0 +1,67 @@ +#include + +#include "esphome/components/tee501/tee501.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tee501::testing { +namespace { + +constexpr uint8_t ADDRESS = 0x40; + +// Answers a measurement read with the raw temperature word (and 50 %RH), each followed by its CRC. +class FakeBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + const uint8_t words[2][2] = {{static_cast(this->raw >> 8), static_cast(this->raw)}, {0x13, 0x88}}; + for (size_t i = 0; i + 3 <= out_size; i += 3) { + out[i] = words[i / 3][0]; + out[i + 1] = words[i / 3][1]; + out[i + 2] = crc8(words[i / 3], 2, 0xFF, 0x31, true); + } + return i2c::ERROR_OK; + } + + uint16_t raw{0}; +}; + +class TEE501TemperatureTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.set_i2c_bus(&this->bus_); + this->sensor_.set_i2c_address(ADDRESS); + } + + // update() sends the measure command and reads the answer 50 ms later. + float measure_(uint16_t raw) { + this->bus_.raw = raw; + this->sensor_.update(); + delay(60); // NOLINT + App.scheduler.call(millis()); + return this->temperature_.state; + } + + FakeBus bus_; + TEE501Component sensor_; + sensor::Sensor &temperature_{this->sensor_}; +}; + +// The sensor sends hundredths of a degree as a signed 16-bit value. +} // namespace + +TEST_F(TEE501TemperatureTest, DecodesSignedHundredths) { + EXPECT_FLOAT_EQ(this->measure_(0x0960), 24.0f); + EXPECT_FLOAT_EQ(this->measure_(0x34BC), 135.0f); + EXPECT_FLOAT_EQ(this->measure_(0xFFFF), -0.01f); + EXPECT_FLOAT_EQ(this->measure_(0xF060), -40.0f); +} + +TEST_F(TEE501TemperatureTest, DecodesTheWholeSignedRange) { + EXPECT_FLOAT_EQ(this->measure_(0xD8F0), -100.0f); + EXPECT_FLOAT_EQ(this->measure_(0x8000), -327.68f); +} + +} // namespace esphome::tee501::testing diff --git a/tests/components/xiaomi_ble/test_xiaomi_device.cpp b/tests/components/xiaomi_ble/test_xiaomi_device.cpp index 9ad7fb6fd0a..32ea32ecc8a 100644 --- a/tests/components/xiaomi_ble/test_xiaomi_device.cpp +++ b/tests/components/xiaomi_ble/test_xiaomi_device.cpp @@ -21,6 +21,10 @@ static const std::vector PLAIN_FRAME = {0x50, 0x20, 0xAA, 0x01, 0x05, 0 static const std::vector ENCRYPTED_FRAME = {0x58, 0x58, 0x5B, 0x05, 0x07, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x19, 0x5F, 0x86, 0x6A, 0x56, 0x01, 0x02, 0x03, 0xA4, 0x77, 0xE8, 0x30}; +// LYWSD02MMC hardware revision F3_A1 (product ID 0x16E4), no MAC, frame count 9, +// temperature 23.5 °C, encrypted with BINDKEY for MAC +static const std::vector LYWSD02MMC_16E4_FRAME = {0x48, 0x58, 0xE4, 0x16, 0x09, 0x43, 0xCD, 0x6D, 0x59, 0x95, + 0x4F, 0x60, 0x01, 0x02, 0x03, 0xD9, 0xE1, 0x32, 0xDF}; static constexpr std::initializer_list BINDKEY = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; static constexpr std::initializer_list WRONG_BINDKEY = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, @@ -111,4 +115,15 @@ TEST(XiaomiDevice, FailedDecryptionDoesNotAdvanceFrameCount) { EXPECT_TRUE(sensor.parse_device(device)); } +TEST(XiaomiDevice, DecryptsLywsd02mmcAlternateProductId) { + auto device = device_from(MAC, LYWSD02MMC_16E4_FRAME); + RecordingEncryptedDevice sensor; + sensor.set_address(device.address_uint64()); + sensor.set_bindkey(BINDKEY); + EXPECT_TRUE(sensor.parse_device(device)); + ASSERT_EQ(sensor.results.size(), 1u); + EXPECT_EQ(sensor.results[0].type, XiaomiParseResult::TYPE_LYWSD02MMC); + EXPECT_FLOAT_EQ(sensor.results[0].temperature.value_or(NAN), 23.5f); +} + } // namespace esphome::xiaomi_ble::testing diff --git a/tests/integration/fixtures/host_ota_automations.yaml b/tests/integration/fixtures/host_ota_automations.yaml new file mode 100644 index 00000000000..bb8ba041f2a --- /dev/null +++ b/tests/integration/fixtures/host_ota_automations.yaml @@ -0,0 +1,32 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ + on_begin: + - logger.log: "AUTOMATION begin" + on_progress: + - lambda: |- + static_assert(std::is_same_v, float>); + ESP_LOGI("test", "AUTOMATION progress %.1f", x); + on_end: + - logger.log: "AUTOMATION end" + on_abort: + - logger.log: "AUTOMATION abort" + on_error: + - lambda: |- + static_assert(std::is_same_v, uint8_t>); + ESP_LOGI("test", "AUTOMATION error %u", x); + # Two automations on one key: each must fire + on_state_change: + - then: + - lambda: |- + static_assert(std::is_same_v, ota::OTAState>); + ESP_LOGI("test", "AUTOMATION state %d", static_cast(state)); + - then: + - lambda: |- + ESP_LOGI("test", "AUTOMATION second state %d", static_cast(state)); +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index f1d414f2c33..fd31e2643c8 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -18,11 +18,13 @@ from pathlib import Path import re import shutil import socket +import time import zlib import pytest from esphome import espota2 +from esphome.helpers import ProgressBar from .conftest import run_binary, wait_and_connect_api_client from .const import ( @@ -644,3 +646,91 @@ async def test_host_ota_rejects_garbage( async with wait_and_connect_api_client(port=dev.api_port) as client: await assert_device_name(client, DEVICE_NAME) + + +class _SlowProgressBar(ProgressBar): + """Pauses once mid upload, longer than the device's progress interval, so + the device reports progress at least once.""" + + _paused = False + + def update(self, progress: float) -> None: + if progress >= 0.5 and not self._paused: + self._paused = True + time.sleep(1.5) + super().update(progress) + + +AUTOMATION_LINE = re.compile(r"AUTOMATION ([^\x1b]+)") +# Pinned on purpose: both on_state_change automations run before on_begin, in config order +BEGIN_LINES = ["state 1", "second state 1", "begin"] + + +def _progress_values(lines: list[str]) -> list[float]: + """Each progress report is both state automations, then on_progress.""" + assert len(lines) % 3 == 0, lines + values: list[float] = [] + for i in range(0, len(lines), 3): + assert lines[i : i + 2] == ["state 2", "second state 2"], lines + values.append(float(lines[i + 2].removeprefix("progress "))) + return values + + +@pytest.mark.asyncio +async def test_host_ota_automations( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + integration_test_dir: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each on_* automation fires for its state with its argument, and every + automation on one key fires.""" + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + bogus_path = integration_test_dir / "bogus.bin" + bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) + + def automation_lines() -> list[str]: + return [ + match.group(1) + for line in dev.waiter.lines + if (match := AUTOMATION_LINE.search(line)) + ] + + async with run_binary( + dev.binary_path, symbols_path=dev.symbols_path, line_callback=dev.on_log + ) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + + # A rejected image: begin, then error with the response code + rc = await _run_ota(dev.ota_port, None, bogus_path, None) + assert rc == 1 + await dev.waiter.wait_count( + "AUTOMATION error", 1, 5.0, "error automation did not fire" + ) + lines = automation_lines() + assert lines[:3] == BEGIN_LINES + assert lines[-3:] == [ + "state 4", + "second state 4", + f"error {espota2.RESPONSE_ERROR_UPDATE_END}", + ] + assert all(0.0 < v <= 100.0 for v in _progress_values(lines[3:-3])) + + # A good image: begin, progress, end, then the reboot + dev.waiter.lines.clear() + with monkeypatch.context() as m: + m.setattr(espota2, "ProgressBar", _SlowProgressBar) + await dev.ota(None, None, "espota2 reported failure") + lines = automation_lines() + assert lines[:3] == BEGIN_LINES + assert lines[-3:] == ["state 0", "second state 0", "end"] + values = _progress_values(lines[3:-3]) + assert any(0.0 < v < 100.0 for v in values), "no progress mid upload" + assert "abort" not in lines diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 6f3b4bb14f7..f6f45683962 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,5 +1,6 @@ """Tests for time component cron expression parsing.""" +from collections.abc import Callable import errno import subprocess import sys @@ -7,7 +8,14 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz +from esphome.components.time import ( + _load_tzdata, + _parse_cron_part, + validate_cron_days_of_week, + validate_cron_hours, + validate_tz, +) +import esphome.config_validation as cv def test_star_slash_seconds() -> None: @@ -83,10 +91,72 @@ def test_range() -> None: assert _parse_cron_part("1-5", 0, 59, {}) == {1, 2, 3, 4, 5} +@pytest.mark.parametrize( + ("validator", "value", "expected"), + [ + (validate_cron_hours, "22-2", [0, 1, 2, 22, 23]), + # Sunday is 1 + (validate_cron_days_of_week, "SAT-MON", [1, 2, 7]), + (validate_cron_days_of_week, "SAT-SUN", [1, 7]), + ], +) +def test_wrapping_range( + validator: Callable[[str], list[int]], value: str, expected: list[int] +) -> None: + assert validator(value) == expected + + +@pytest.mark.parametrize( + ("validator", "value"), + [(validate_cron_hours, "24-2"), (validate_cron_days_of_week, "SAT-0")], +) +def test_range_endpoint_out_of_bounds( + validator: Callable[[str], list[int]], value: str +) -> None: + with pytest.raises(cv.Invalid, match="out of range"): + validator(value) + + def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} +@pytest.mark.parametrize( + ("validator", "value", "expected"), + [ + (validate_cron_hours, "*/6", [0, 6, 12, 18]), + (validate_cron_hours, "/8", [0, 8, 16]), + (validate_cron_hours, "5/7", [5, 12, 19]), + (validate_cron_hours, "23/100", [23]), + # Sunday is 1 + (validate_cron_days_of_week, "MON/2", [2, 4, 6]), + (validate_cron_days_of_week, "?/3", [1, 4, 7]), + ], +) +def test_step_valid( + validator: Callable[[str], list[int]], value: str, expected: list[int] +) -> None: + assert validator(value) == expected + + +@pytest.mark.parametrize( + ("validator", "value", "match"), + [ + (validate_cron_hours, "*/0", "repeat must be at least 1, got 0"), + (validate_cron_hours, "5/-5", "repeat must be at least 1, got -5"), + (validate_cron_hours, "24/1", "offset 24 is out of range"), + (validate_cron_hours, "70/5", "offset 70 is out of range"), + (validate_cron_days_of_week, "0/2", "offset 0 is out of range"), + (validate_cron_days_of_week, "8/1", "offset 8 is out of range"), + ], +) +def test_step_invalid( + validator: Callable[[str], list[int]], value: str, match: str +) -> None: + with pytest.raises(cv.Invalid, match=match): + validator(value) + + def _mock_resources_with_error(error: Exception) -> MagicMock: """Return a mock of importlib.resources.files where read_bytes raises error.""" leaf = MagicMock() diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 5705c0bff57..fa6ac8700df 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2419,6 +2419,13 @@ def test_ssid_too_long() -> None: cv.ssid("x" * 33) +def test_ssid_multibyte_measured_in_bytes() -> None: + # 802.11 limits the SSID to 32 octets, not 32 characters + assert cv.ssid("é" * 16) == "é" * 16 # exactly 32 bytes + with pytest.raises(Invalid, match="longer than 32 bytes \\(got 34 bytes"): + cv.ssid("é" * 17) + + # --------------------------------------------------------------------------- # IP address / network validators # --------------------------------------------------------------------------- @@ -2593,12 +2600,26 @@ def test_conflicts_with_component_not_loaded() -> None: # --------------------------------------------------------------------------- -def test_percentage_int_with_percent() -> None: - assert cv.percentage_int("50%") == 50 +@pytest.mark.parametrize( + ("value", "expected"), + [ + (85, 85), + (0, 0), + (85.0, 85), + ("85", 85), + ("85%", 85), + ("85 %", 85), + ("0%", 0), + ], +) +def test_percentage_int_valid(value: float | str, expected: int) -> None: + assert cv.percentage_int(value) == expected -def test_percentage_int_plain() -> None: - assert cv.percentage_int(50) == 50 +@pytest.mark.parametrize("value", ["abc", "85.5", "85.5%", "%", "", 85.5]) +def test_percentage_int_invalid(value: float | str) -> None: + with pytest.raises(Invalid): + cv.percentage_int(value) def test_invalid_always_raises() -> None: @@ -2876,6 +2897,18 @@ def test_dimensions_string_invalid() -> None: cv.dimensions("notdimensions") +@pytest.mark.parametrize("value", ["320x240px", "320x240,", "320x240junk"]) +def test_dimensions_string_trailing_garbage(value: str) -> None: + # re.match() left the pattern unanchored at the end, so trailing garbage + # was silently dropped instead of rejected + with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"): + cv.dimensions(value) + + +def test_dimensions_string_whitespace_still_valid() -> None: + assert cv.dimensions(" 320 x 240 ") == [320, 240] + + # --------------------------------------------------------------------------- # entity_id # --------------------------------------------------------------------------- @@ -2895,6 +2928,14 @@ def test_entity_id_invalid_char() -> None: cv.entity_id("light.living!room") +@pytest.mark.parametrize("value", ["sensor.", ".sensor", "."]) +def test_entity_id_empty_part(value: str) -> None: + # The per-character check never ran for an empty part, so a trailing or + # leading dot silently passed validation + with pytest.raises(Invalid, match="form '\\.'"): + cv.entity_id(value) + + # --------------------------------------------------------------------------- # extract_keys / typed_schema # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8723a5e8699..727a1e53cf8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -20,7 +20,7 @@ from pytest import CaptureFixture import serial from zeroconf import ServiceStateChange -from esphome import __main__ as main, yaml_util +from esphome import __main__ as main, platform_hooks, yaml_util from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -57,6 +57,7 @@ from esphome.__main__ import ( has_name_add_mac_suffix, has_non_ip_address, has_ota, + has_platform_network_ota, has_resolvable_address, has_web_server_logging, has_web_server_ota, @@ -4243,6 +4244,38 @@ def test_has_ota() -> None: assert has_ota() is False +@pytest.mark.parametrize( + ("ota", "platform", "expected"), + [ + ({"transport": {"udp": True}}, PLATFORM_NRF52, True), + ({"transport": {"ble": True}}, PLATFORM_NRF52, False), + ({}, PLATFORM_NRF52, False), + ({"transport": {"udp": True}}, PLATFORM_ESP32, False), + ({"transport": {"udp": True}}, None, False), + ], +) +def test_has_platform_network_ota( + ota: dict[str, Any], platform: str | None, expected: bool +) -> None: + """Only nRF52 zephyr_mcumgr with the UDP transport is a network OTA.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: "zephyr_mcumgr", **ota}]}, + platform=platform, + ) + assert has_platform_network_ota() is expected + assert has_ota() is expected + + +def test_has_platform_network_ota_external_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An external target platform's has_network_ota hook is probed too.""" + module = SimpleNamespace(has_network_ota=lambda config: True) + monkeypatch.setattr(platform_hooks, "import_module", lambda name: module) + setup_core(config={}, platform="my_external_chip") + assert has_platform_network_ota() is True + + def test_get_port_type() -> None: """Test get_port_type function.""" diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py index c0b5e202f70..1368e1f28eb 100644 --- a/tests/unit_tests/test_nrf52_ota.py +++ b/tests/unit_tests/test_nrf52_ota.py @@ -1,13 +1,21 @@ """Tests for nRF52 BLE device discovery and OTA transport selection.""" import asyncio +from collections.abc import Callable +import logging from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +from smp.image_management import ( + ImageStatesReadResponse, + ImageUploadWriteRequest, + ImageUploadWriteResponse, +) +from smpclient.requests.image_management import ImageStatesRead -from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.components.nrf52 import ble_logger, ota, ota_udp, show_logs from esphome.core import EsphomeError MAC = "AA:BB:CC:DD:EE:FF" @@ -64,17 +72,24 @@ def test_scan_raises_when_no_device_found(monkeypatch) -> None: @pytest.mark.parametrize( - ("device", "transport"), - [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], + ("device", "udp", "transport"), + [ + ("/dev/ttyACM0", False, "serial"), + ("COM3", False, "serial"), + (MAC, False, "ble"), + (UUID, False, "ble"), + ("fd00::1", True, "udp"), + ], ) -def test_upload_transport(monkeypatch, device: str, transport: str) -> None: - """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" +def test_upload_transport(monkeypatch, device: str, udp: bool, transport: str) -> None: + """The caller picks UDP; otherwise MAC addresses and macOS UUIDs go to BLE, the rest to serial.""" captured: dict = {} class FakeClient: - def __init__(self, transport, address): + def __init__(self, transport, address, timeout_s: float = 2.5): captured["transport"] = transport captured["address"] = address + captured["timeout_s"] = timeout_s async def connect(self) -> None: pass @@ -86,10 +101,15 @@ def test_upload_transport(monkeypatch, device: str, transport: str) -> None: monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota_udp, "_SequenceMatchingUDPTransport", lambda mtu: "udp") monkeypatch.setattr(ota, "SMPClient", FakeClient) - asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access - assert captured == {"transport": transport, "address": device} + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"), udp)) # pylint: disable=protected-access + assert captured == { + "transport": transport, + "address": device, + "timeout_s": ota_udp.SMP_UDP_PROBE_TIMEOUT if udp else 2.5, + } @pytest.mark.parametrize( @@ -119,3 +139,412 @@ def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: assert show_logs(config={}, args=None, devices=["BLE"]) is True connect.assert_not_called() + + +@pytest.fixture +def smp_ok(monkeypatch: pytest.MonkeyPatch) -> None: + """Take every fake reply as a successful SMP response.""" + for module in (ota, ota_udp): + monkeypatch.setattr(module, "error", lambda response: False) + monkeypatch.setattr(module, "success", lambda response: True) + + +def _queue_client( + reply: Callable[[bytes], list[bytes]], timeout_s: float = 0.05 +) -> ota.SMPClient: + """A real SMPClient over the UDP transport, fed by reply(request) frames.""" + transport = ota_udp._SequenceMatchingUDPTransport(mtu=1280) # pylint: disable=protected-access + queue: asyncio.Queue[bytes] = asyncio.Queue() + + async def send(data: bytes) -> None: + for frame in reply(data): + queue.put_nowait(frame) + + transport.send = send + transport.receive = queue.get + return ota.SMPClient(transport, "fd00::1", timeout_s=timeout_s) + + +IMAGE = bytes(range(256)) * 20 +PROGRESS = SimpleNamespace(update=lambda _: None) + + +class FakeDevice: + """Answers image writes and state reads like mcumgr. + + A write at the wrong offset gets the offset the device wants. Each scripted + reply is an exception to raise, an offset to report, or None to accept. Like + Zephyr's img_mgmt, the device clears its upload state after the last write. + """ + + def __init__( + self, replies: list | None = None, lose_final_reply: bool = False + ) -> None: + self.replies = replies or [] + self.lose_final_reply = lose_final_reply + self.off = 0 + self.stored = False + self.writes: list[int] = [] + + def get_max_cbor_and_data_size(self, request) -> tuple[int, int]: + return 0, 1000 + + async def request(self, request, timeout_s=None): + if isinstance(request, ImageStatesRead): + stored = b"hash" if self.stored else b"old" + return SimpleNamespace(images=[SimpleNamespace(hash=stored)]) + self.writes.append(request.off) + reply = self.replies.pop(0) if self.replies else None + if isinstance(reply, BaseException): + raise reply + if reply is not None: + self.off = reply + elif request.off == self.off: + self.off += len(request.data) + if self.off == len(IMAGE): + self.stored = True + self.off = 0 + if self.lose_final_reply: + self.lose_final_reply = False + raise TimeoutError + return SimpleNamespace(off=len(IMAGE), match=True) + return SimpleNamespace(off=self.off, match=None) + + +def _udp_upload(device: FakeDevice) -> None: + asyncio.run(ota_udp._udp_upload(device, IMAGE, PROGRESS, "fd00::1", b"hash")) # pylint: disable=protected-access + + +@pytest.mark.parametrize( + ("device", "writes"), + [ + # Lost replies are resent + ( + FakeDevice([TimeoutError(), None, TimeoutError(), None]), + [0, 0, 1000, 1000, 2000, 3000, 4000, 5000], + ), + # A device that restarted the upload is sent the image again from its offset + (FakeDevice([None, None, 0]), [0, 1000, 2000, 0, 1000, 2000, 3000, 4000, 5000]), + # The resent final chunk gets offset 0; the image is in the slot, so it is done + (FakeDevice(lose_final_reply=True), [0, 1000, 2000, 3000, 4000, 5000, 5000]), + ], +) +def test_udp_upload(smp_ok: None, device: FakeDevice, writes: list[int]) -> None: + _udp_upload(device) + assert device.writes == writes + + +@pytest.mark.parametrize( + ("replies", "message"), + [ + ([0] * (ota_udp.SMP_UDP_UPLOAD_RETRIES + 1), "not advancing past offset 0"), + # Going back to 0 never sets a new high, so repeated restarts stop too + ( + [None, 0] * (ota_udp.SMP_UDP_UPLOAD_RETRIES + 1), + "not advancing past offset 1000", + ), + ( + [TimeoutError()] * (ota_udp.SMP_UDP_UPLOAD_RETRIES + 1), + "stalled at offset 0", + ), + ([len(IMAGE) + 1], "past"), + ], +) +def test_udp_upload_gives_up(smp_ok: None, replies: list, message: str) -> None: + with pytest.raises(EsphomeError, match=message): + _udp_upload(FakeDevice(replies)) + + +def test_udp_upload_reports_a_hash_mismatch(smp_ok: None) -> None: + device = FakeDevice() + + async def request(request, timeout_s=None): + return SimpleNamespace(off=len(IMAGE), match=False) + + device.request = request + with pytest.raises(EsphomeError, match="mismatched SHA256"): + _udp_upload(device) + + +def test_udp_upload_skips_a_late_reply(monkeypatch: pytest.MonkeyPatch) -> None: + """A reply to a timed-out attempt is still queued when the resend goes out. + + The plain transport would read it as the answer to the resend and stay one + reply behind for the rest of the upload. + """ + monkeypatch.setattr(ota_udp, "SMP_UDP_REQUEST_TIMEOUT", 0.05) + state = {"off": 0, "sent": 0, "late": []} + + def reply(data: bytes) -> list[bytes]: + state["sent"] += 1 + request = ImageUploadWriteRequest.loads(data) + if request.off == state["off"]: + state["off"] += len(request.data) + frame = bytes( + ImageUploadWriteResponse( + sequence=request.header.sequence, + off=state["off"], + match=True if state["off"] == len(IMAGE) else None, + ) + ) + if state["sent"] == 1: + state["late"].append(frame) # lost until the resend goes out + return [] + frames = [*state["late"], frame] + state["late"].clear() + return frames + + asyncio.run( + ota_udp._udp_upload(_queue_client(reply), IMAGE, PROGRESS, "fd00::1", b"hash") # pylint: disable=protected-access + ) + # One resend for the lost reply, then one request per chunk + assert state["sent"] == len(range(0, len(IMAGE), 1252)) + 1 + + +def test_udp_request_failure_names_the_target() -> None: + client = SimpleNamespace(request=AsyncMock(side_effect=TimeoutError())) + with pytest.raises( + EsphomeError, match="fd00::1 on UDP port 1337 could not be reached" + ): + asyncio.run( + ota._request_with_retry( # pylint: disable=protected-access + client, ImageStatesRead, None, 2, "fd00::1 on UDP port 1337" + ) + ) + assert client.request.await_count == 3 + + +def test_udp_no_reply_to_the_first_request_is_unreachable() -> None: + """The caller tries the next address when the device never answers.""" + client = SimpleNamespace(request=AsyncMock(side_effect=TimeoutError())) + with pytest.raises(ota_udp.SMPUnreachableError, match="could not be reached"): + asyncio.run( + ota._smpmgr_upload_connected( # pylint: disable=protected-access + client, "fd00::5", Path("firmware.bin"), b"", True + ) + ) + + +def test_image_in_slot_logs_why_it_sends_again( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + client = SimpleNamespace(request=AsyncMock(return_value="rc=8")) + monkeypatch.setattr(ota_udp, "success", lambda response: False) + assert not asyncio.run(ota_udp._image_in_slot(client, b"hash", "fd00::5")) # pylint: disable=protected-access + assert "Could not read the image states" in caplog.text + assert "rc=8" in caplog.text + + +@pytest.mark.parametrize("udp", [True, False]) +def test_upload_hides_smpclient_timeout_errors_on_udp( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, udp: bool +) -> None: + """A recovered timeout logs no smpclient ERROR on UDP; BLE and serial keep it.""" + sent: list[bytes] = [] + + def reply(data: bytes) -> list[bytes]: + sent.append(data) + if len(sent) == 1: + return [] + return [bytes(ImageStatesReadResponse(sequence=data[6], images=[]))] + + async def upload(device: str, firmware: Path, udp: bool) -> None: + client = _queue_client(reply, timeout_s=0.02) + await ota._request_with_retry(client, ImageStatesRead, None, 1, device) # pylint: disable=protected-access + + monkeypatch.setattr(ota, "_smpmgr_upload", upload) + asyncio.run(ota.smpmgr_upload("fd00::1", Path("firmware.bin"), udp)) + errors = [ + r + for r in caplog.records + if r.name == "smpclient" and r.levelno >= logging.ERROR + ] + assert bool(errors) is not udp + assert not logging.getLogger("smpclient").filters + + +class ResetDevice: + """Plays out each ResetWrite: "ok", "lost" (never arrives) or "applied" (reply lost). + + "rebooting" loses the reply and keeps the device quiet for longer than the short + wait after a lost reply, as a reset that took effect late does. With swaps=False the device reboots into the old image, as MCUboot does with an + image too large for its slot. + """ + + def __init__( + self, + outcomes: list[str], + answers: bool = True, + ours: bool = True, + swaps: bool = True, + silent: int = 0, + ) -> None: + self.outcomes = outcomes + self.answers = answers + self.silent = silent + self.ours = ours + self.swaps = swaps + self.new_image = False + self.resets = 0 + self.quiet_until = 0.0 + self.late = False + + async def request(self, request, timeout_s=None): + if isinstance(request, ota.ResetWrite): + self.resets += 1 + outcome = self.outcomes.pop(0) + if outcome == "ok": + self.new_image = self.swaps + return SimpleNamespace() + if outcome == "rebooting": + self.quiet_until = asyncio.get_running_loop().time() + 0.15 + self.new_image = self.swaps + self.late = outcome == "late" + self.new_image = self.new_image or outcome == "applied" + raise TimeoutError + if asyncio.get_running_loop().time() < self.quiet_until: + raise TimeoutError + if not self.answers or self.silent: + self.silent = max(self.silent - 1, 0) + raise TimeoutError + image = SimpleNamespace( + hash=b"hash" if self.ours else b"other", + active=self.new_image, + pending=not self.new_image, + ) + if self.late: + # The old image answers once, then the reset takes effect + self.late = False + self.quiet_until = asyncio.get_running_loop().time() + 0.15 + self.new_image = self.swaps + return SimpleNamespace(images=[image]) + + +@pytest.mark.parametrize( + ("device", "resets", "result"), + [ + # The reset took effect and only its reply was lost: never reset the new image + (ResetDevice(["applied"]), 1, "ok"), + # The reset never arrived and the old image still runs: resend once + (ResetDevice(["lost", "ok"]), 2, "ok"), + ( + ResetDevice(["lost", "lost"]), + 2, + "still runs the old image.*Restart the device to apply the update", + ), + (ResetDevice(["lost"], ours=False), 1, "no longer has the new image"), + # No answer at all: assume it is rebooting + (ResetDevice(["lost"], answers=False), 1, "warning"), + # A confirmed reset is followed until the new image runs + (ResetDevice(["ok"]), 1, "ok"), + (ResetDevice(["ok"], swaps=False), 1, "did not swap in the new image"), + (ResetDevice(["ok"], answers=False), 1, "warning"), + # The first reset took effect late, the resend got no reply and the device + # stayed quiet past the short wait: wait for the reboot, not just warn + (ResetDevice(["lost", "rebooting"]), 2, "ok"), + ( + ResetDevice(["lost", "rebooting"], swaps=False), + 2, + "did not swap in the new image", + ), + # The old image answered once and went quiet before the short wait ended: + # the reset took effect, so it is not sent again + (ResetDevice(["late"]), 1, "ok"), + # Replies lost and the image too large: the old image is reset once more + ( + ResetDevice(["lost", "ok"], silent=1, swaps=False), + 2, + "did not swap in the new image", + ), + ], +) +def test_udp_reset( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + smp_ok: None, + device: ResetDevice, + resets: int, + result: str, +) -> None: + monkeypatch.setattr(ota_udp, "SMP_UDP_PROBE_TIMEOUT", 0) + caplog.set_level(logging.INFO) + monkeypatch.setattr(ota_udp, "SMP_UDP_REBOOT_WAIT", 0.3) + monkeypatch.setattr(ota_udp, "SMP_UDP_LOST_RESET_WAIT", 0.05) + run = ota_udp._udp_reset(device, "fd00::5", b"hash") # pylint: disable=protected-access + if result in ("ok", "warning"): + asyncio.run(run) + else: + with pytest.raises(EsphomeError, match=result): + asyncio.run(run) + assert device.resets == resets + warned = any( + r.levelname == "WARNING" and "check that it runs the new image" in r.message + for r in caplog.records + ) + assert warned is (result == "warning") + confirmed = any( + r.levelname == "INFO" and r.message.endswith("runs the new image") + for r in caplog.records + ) + assert confirmed is (result == "ok") + + +def test_udp_reset_probe_error_reply(monkeypatch) -> None: + """An error reply to the probe is a failed read, not a missing image.""" + monkeypatch.setattr(ota_udp, "error", lambda response: False) + monkeypatch.setattr(ota_udp, "success", lambda response: False) + monkeypatch.setattr(ota_udp, "SMP_UDP_PROBE_TIMEOUT", 0) + monkeypatch.setattr(ota_udp, "SMP_UDP_LOST_RESET_WAIT", 0.05) + device = ResetDevice(["lost"]) + with pytest.raises(EsphomeError, match="answered with an error"): + asyncio.run(ota_udp._udp_reset(device, "fd00::5", b"hash")) # pylint: disable=protected-access + assert device.resets == 1 + + +def _state_client(*images: SimpleNamespace) -> SimpleNamespace: + return SimpleNamespace( + request=AsyncMock(return_value=SimpleNamespace(images=list(images))) + ) + + +def _slot(hash_: bytes, active: bool, pending: bool) -> SimpleNamespace: + return SimpleNamespace( + hash=hash_, active=active, pending=pending, confirmed=active, slot=0 + ) + + +@pytest.mark.parametrize("udp", [True, False]) +def test_upload_refuses_a_stale_pending_image(smp_ok: None, udp: bool) -> None: + """An earlier update MCUboot never swapped in blocks the slot on every transport.""" + client = _state_client( + _slot(b"running", active=True, pending=False), + _slot(b"earlier", active=False, pending=True), + ) + with pytest.raises( + EsphomeError, + match="earlier update pending. Restart the device to apply it.*over USB", + ): + asyncio.run( + ota._smpmgr_upload_connected( # pylint: disable=protected-access + client, "fd00::5", Path("firmware.bin"), b"new", udp + ) + ) + assert client.request.await_count == 1 + + +def test_upload_keeps_the_same_pending_image( + monkeypatch: pytest.MonkeyPatch, smp_ok: None +) -> None: + """Our own pending image is marked and reset again, not refused as stale.""" + monkeypatch.setattr(ota, "RESET_DELAY", 0) + client = _state_client( + _slot(b"running", active=True, pending=False), + _slot(b"new", active=False, pending=True), + ) + asyncio.run( + ota._smpmgr_upload_connected( # pylint: disable=protected-access + client, "/dev/ttyACM0", Path("firmware.bin"), b"new", False + ) + ) + # Image states, mark for test, reset; no upload + assert client.request.await_count == 3 diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 287bb5c1863..f2ee919384c 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -3,10 +3,13 @@ import asyncio from contextlib import ExitStack from pathlib import Path +import socket +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from esphome.address_cache import AddressCache from esphome.components.nrf52.const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, BOOTLOADER_NRF, @@ -620,11 +623,151 @@ class TestUploadProgramBle: ) assert mock_scan.await_count == (host == "BLE") - mock_smp.assert_awaited_once_with(BLE_MAC) + mock_smp.assert_awaited_once_with(BLE_MAC, None) mock_enter.assert_not_awaited() mock_dfu.assert_not_awaited() +class TestUploadProgramUdp: + """A network host goes over MCUmgr UDP only when zephyr_mcumgr enables it.""" + + def _run( + self, + tmp_path: Path, + host: str, + transport: dict, + args: object = None, + network: dict | None = None, + ) -> tuple[bool, AsyncMock]: + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + config = {"ota": [{"platform": "zephyr_mcumgr", "transport": transport}]} + if network is not None: + config["network"] = network + CORE.config = config + with patch( + "esphome.components.nrf52._smpmgr_upload", new=AsyncMock() + ) as mock_smp: + result = upload_program(config=config, args=args, host=host) + return result, mock_smp + + @pytest.mark.parametrize( + ("host", "network"), + [ + ("fd00::1", None), + ("fd00::1", {"enable_ipv4": False}), + ("192.168.1.10", {"enable_ipv4": True}), + ], + ) + def test_udp_transport_uploads( + self, setup_core: Path, tmp_path: Path, host: str, network: dict | None + ) -> None: + result, mock_smp = self._run(tmp_path, host, {"udp": True}, network=network) + assert result is True + mock_smp.assert_awaited_once_with(host, None, udp=True) + + def test_ipv4_address_without_ipv4_listener( + self, setup_core: Path, tmp_path: Path + ) -> None: + with pytest.raises(EsphomeError, match="enable_ipv4: true"): + self._run(tmp_path, "192.168.1.10", {"udp": True}) + + def test_resolves_through_the_address_cache( + self, setup_core: Path, tmp_path: Path + ) -> None: + CORE.address_cache = AddressCache(mdns_cache={"my-device.local": ["fd00::5"]}) + result, mock_smp = self._run(tmp_path, "my-device.local", {"udp": True}) + assert result is True + mock_smp.assert_awaited_once_with("fd00::5", None, udp=True) + + def test_resolves_every_cached_address( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The CLI passes only the first cached address; the others are kept too.""" + from esphome.components.nrf52 import _resolve_smp_udp_hosts + + CORE.config = {"openthread": {"use_address": "my-device.local"}} + CORE.address_cache = AddressCache( + mdns_cache={"my-device.local": ["fd00::5", "fd00::6"]} + ) + assert _resolve_smp_udp_hosts("fd00::5", False) == ["fd00::5", "fd00::6"] + # An address the user named on its own is not widened + assert _resolve_smp_udp_hosts("fd00::7", False) == ["fd00::7"] + + def test_uploads_the_requested_file(self, setup_core: Path, tmp_path: Path) -> None: + args = SimpleNamespace(file="other.bin") + result, mock_smp = self._run(tmp_path, "fd00::1", {"udp": True}, args) + assert result is True + mock_smp.assert_awaited_once_with("fd00::1", "other.bin", udp=True) + + def test_without_udp_transport_falls_through( + self, setup_core: Path, tmp_path: Path + ) -> None: + result, mock_smp = self._run(tmp_path, "192.168.1.10", {"ble": True}) + assert result is False + mock_smp.assert_not_awaited() + + +def test_resolve_keeps_every_address() -> None: + """In resolve_ip_address order, IPv4 only when the network enables it.""" + from esphome.components.nrf52 import _resolve_smp_udp_hosts + + with patch( + "esphome.helpers.resolve_ip_address", + return_value=[ + (socket.AF_INET6, 0, 0, "", ("fd00::5", 1337, 0, 0)), + (socket.AF_INET6, 0, 0, "", ("fd00::5", 1337, 0, 0)), + (socket.AF_INET, 0, 0, "", ("192.168.1.10", 1337)), + (socket.AF_INET6, 0, 0, "", ("fe80::1", 1337, 0, 7)), + ], + ): + assert _resolve_smp_udp_hosts("my-device.local", True) == [ + "fd00::5", + "192.168.1.10", + "fe80::1%7", + ] + assert _resolve_smp_udp_hosts("my-device.local", False) == [ + "fd00::5", + "fe80::1%7", + ] + + +@pytest.mark.parametrize( + ("outcomes", "tried", "error"), + [ + # An address that does not answer is skipped + (["unreachable", "ok"], 2, None), + # A failure after the device answered is final + (["stalled"], 1, "stalled"), + (["unreachable", "unreachable"], 2, "no reply"), + ], +) +def test_udp_upload_tries_the_next_address( + setup_core: Path, outcomes: list[str], tried: int, error: str | None +) -> None: + from esphome.components.nrf52 import _upload_over_udp + from esphome.components.nrf52.ota_udp import SMPUnreachableError + + effects = { + "ok": None, + "unreachable": SMPUnreachableError("no reply"), + "stalled": EsphomeError("Upload stalled"), + } + mock_smp = AsyncMock(side_effect=[effects[outcome] for outcome in outcomes]) + run = _upload_over_udp("my-device.local", ["fd00::5", "fd00::6"], None) + with patch("esphome.components.nrf52._smpmgr_upload", new=mock_smp): + if error is None: + asyncio.run(run) + else: + with pytest.raises(EsphomeError, match=error): + asyncio.run(run) + assert [c.args[0] for c in mock_smp.await_args_list] == ["fd00::5", "fd00::6"][ + :tried + ] + + class TestUploadOverBle: def test_name_scan_miss_hints_at_bootloader_address( self, setup_core: Path, tmp_path: Path @@ -646,3 +789,97 @@ class TestUploadOverBle: _upload_over_ble({"ota": [{"platform": "adafruit_ble"}]}, "BLE") ) assert str(err.value).endswith(dfu.BOOTLOADER_HINT) + + +@pytest.mark.parametrize("file", [None, "other.bin"]) +def test_smpmgr_upload_sends_the_requested_file( + setup_core: Path, tmp_path: Path, file: str | None +) -> None: + """--file replaces the build's app_update.bin.""" + from esphome.components.nrf52 import _smpmgr_upload + + _setup_nrf52_core(build_path=tmp_path / "build") + with patch( + "esphome.components.nrf52.ota.smpmgr_upload", new=AsyncMock() + ) as mock_upload: + asyncio.run(_smpmgr_upload("fd00::1", file, udp=True)) + expected = ( + Path(file) + if file + else CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin") + ) + mock_upload.assert_awaited_once_with("fd00::1", expected.resolve(), True) + + +ITSYBITSY_PARTITIONS = """\ +mcuboot_primary: + address: 0xc000 + size: 0x61000 +mcuboot_secondary: + address: 0x6d000 + size: 0x61000 +""" + + +SWAP_MOVE = "CONFIG_BOOT_SWAP_USING_MOVE=y\n" +MAX_SECTORS = "CONFIG_BOOT_MAX_IMG_SECTORS=128\n" + + +def _mcuboot_build(tmp_path: Path, image_size: int, config: str) -> Path: + (tmp_path / "partitions.yml").write_text(ITSYBITSY_PARTITIONS) + config_path = tmp_path / "mcuboot" / "zephyr" / ".config" + config_path.parent.mkdir(parents=True) + config_path.write_text(config) + image = tmp_path / "zephyr.signed.bin" + image.write_bytes(b"\0" * image_size) + return image + + +@pytest.mark.parametrize( + ("image_size", "config", "error"), + [ + (389_120, SWAP_MOVE + MAX_SECTORS, None), + (300_000, SWAP_MOVE + MAX_SECTORS, None), + # The ItsyBitsy's 97 sector slot swaps in 95 sectors: 389,120 bytes + ( + 392_155, + SWAP_MOVE + MAX_SECTORS, + "392155 bytes, but MCUboot can only swap in 389120 bytes.*at least 3035", + ), + (392_155, "CONFIG_BOOT_UPGRADE_ONLY=y\n", None), + ], +) +def test_mcuboot_swap_limit( + tmp_path: Path, image_size: int, config: str, error: str | None +) -> None: + from esphome.components.nrf52 import _check_mcuboot_swap_limit + + image = _mcuboot_build(tmp_path, image_size, config) + if error is None: + _check_mcuboot_swap_limit(tmp_path, image) + else: + with pytest.raises(EsphomeError, match=error): + _check_mcuboot_swap_limit(tmp_path, image) + + +@pytest.mark.parametrize( + ("config", "remove"), + [ + (SWAP_MOVE + MAX_SECTORS, "partitions.yml"), + (SWAP_MOVE + MAX_SECTORS, "zephyr.signed.bin"), + (SWAP_MOVE, None), + ], +) +def test_mcuboot_swap_limit_warns_when_it_cannot_check( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + config: str, + remove: str | None, +) -> None: + from esphome.components.nrf52 import _check_mcuboot_swap_limit + + image = _mcuboot_build(tmp_path, 392_155, config) + if remove is not None: + (tmp_path / remove).unlink() + _check_mcuboot_swap_limit(tmp_path, image) + assert "Skipping the MCUboot slot size check" in caplog.text diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index af9ec4a8377..c1268c6b95d 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -200,10 +200,24 @@ def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None: @pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only") -def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("python_dir", "first_line"), + [("bin", "#!/usr/bin/env -S"), ("Application Support", "#!/bin/sh")], +) +def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus( + tmp_path: Path, python_dir: str, first_line: str +) -> None: + """The wrapper runs from any Python; one under a path with a space, as + the ESPHome Builder app on macOS installs it, is started through sh.""" cxx, real = _fake_toolchain(tmp_path) - _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + # A link under tmp_path, so the case does not depend on where the test's own Python lives + link = tmp_path / python_dir / "python3" + link.parent.mkdir() + link.symlink_to(sys.executable) + with patch.object(sys, "executable", str(link)): + _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + assert wrapper.read_text(encoding="utf-8").startswith(first_line) result = subprocess.run( [str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False )