From 80fa6efd1e2fd117b2f5ae601fc60fe56b3661ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:44:49 -1000 Subject: [PATCH 01/66] [core] Document string tables, shared CRC helpers, NaN sentinels and polling start-up in AGENTS.md (#20599) --- AGENTS.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6bdd9467c01..5031c4a457f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,21 @@ file does, and it is the authority when they disagree. The most useful starting `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array a `TEMPLATABLE_BYTES` field so a static code stays in flash. + * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), + logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. + A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. + * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`); + pick the helper's polynomial and bit-order arguments instead of writing a CRC loop. + * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. + * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the + end of `setup()` when the entities need a state at boot. + * **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing + resend and give-up logic handles it; never advance on an answer that was only logged. + * **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python + tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual + listener base (a second vptr per entity). + * **A global feature define** says the code is compiled in, not that this instance uses it: keep the + per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a @@ -589,7 +604,9 @@ file does, and it is the authority when they disagree. The most useful starting `script/cpp_unit_test.py `; see `tests/components/README.md`. Set the loop clock in a test with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test. * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove - timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its + `responses` match on content every time they see it; to answer a repeated request differently, use an + `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs on. A variant per board for platform-identical code only adds CI time. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: From 8aab968136acb4867fc170c3eb4eab03c48759a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Doherty Date: Mon, 12 Oct 2026 17:11:10 -0700 Subject: [PATCH 02/66] [aranet] Add Aranet BLE sensor support (#18263) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/aranet/__init__.py | 2 + esphome/components/aranet/aranet.cpp | 157 ++++++++++++++++++ esphome/components/aranet/aranet.h | 42 +++++ esphome/components/aranet/sensor.py | 150 +++++++++++++++++ tests/components/aranet/common-ln.yaml | 7 + tests/components/aranet/common.h | 70 ++++++++ tests/components/aranet/common.yaml | 27 +++ .../components/aranet/sensor/test_aranet.cpp | 144 ++++++++++++++++ tests/components/aranet/test.esp32-idf.yaml | 3 + tests/components/aranet/test.ln882x-ard.yaml | 3 + .../aranet/validate.bk72xx-ard.yaml | 12 ++ 12 files changed, 618 insertions(+) create mode 100644 esphome/components/aranet/__init__.py create mode 100644 esphome/components/aranet/aranet.cpp create mode 100644 esphome/components/aranet/aranet.h create mode 100644 esphome/components/aranet/sensor.py create mode 100644 tests/components/aranet/common-ln.yaml create mode 100644 tests/components/aranet/common.h create mode 100644 tests/components/aranet/common.yaml create mode 100644 tests/components/aranet/sensor/test_aranet.cpp create mode 100644 tests/components/aranet/test.esp32-idf.yaml create mode 100644 tests/components/aranet/test.ln882x-ard.yaml create mode 100644 tests/components/aranet/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fab41f5b1fa..075b8113d88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -44,6 +44,7 @@ esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex +esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr diff --git a/esphome/components/aranet/__init__.py b/esphome/components/aranet/__init__.py new file mode 100644 index 00000000000..d1d75568e94 --- /dev/null +++ b/esphome/components/aranet/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@patrickod"] +DOMAIN = "aranet" diff --git a/esphome/components/aranet/aranet.cpp b/esphome/components/aranet/aranet.cpp new file mode 100644 index 00000000000..4fa28eec17a --- /dev/null +++ b/esphome/components/aranet/aranet.cpp @@ -0,0 +1,157 @@ +#include "aranet.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::aranet { + +ESPHOME_LOG_TAG(TAG, "aranet"); +static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702; +static constexpr size_t ARANET4_DATA_LENGTH = 22; +static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24; + +enum class AranetDeviceType : uint8_t { + ARANET_DEVICE_TYPE_ARANET4 = 0, + ARANET_DEVICE_TYPE_ARANET2 = 1, + ARANET_DEVICE_TYPE_RADIATION = 2, + ARANET_DEVICE_TYPE_RADON = 3, +}; + +static inline void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); +} + +// Aranet does not publish an official advertisement specification. The layout below is corroborated by these +// independently maintained implementations: +// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py +// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h + +void Aranet::dump_config() { + ESP_LOGCONFIG(TAG, "Aranet"); + LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_); + LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_); + LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_); + LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_); + LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_); +} + +bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID); + for (const auto &manufacturer_data : device.get_manufacturer_datas()) { + if (manufacturer_data.uuid != manufacturer_uuid) + continue; + + const auto &data = manufacturer_data.data; + if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) { + if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) { + ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast(data.size())); + this->warned_ |= WARNED_INVALID_LENGTH; + } + return false; + } + + // Aranet4 has no device type byte, so its flags and trailer start one byte earlier + const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH; + if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) { + if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) { + ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device"); + this->warned_ |= WARNED_INTEGRATION_DISABLED; + } + return false; + } + + if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) { + if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) { + ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]); + this->warned_ |= WARNED_UNSUPPORTED_TYPE; + } + return false; + } + const auto device_type = + is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast(data[0]); + + // Battery, measurement interval, measurement age and measurement counter + const uint8_t *trailer = &data[is_aranet4 ? 15 : 17]; + const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]); + const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]); + const uint8_t measurement_counter = trailer[6]; + + publish_if_set(this->measurement_age_sensor_, measurement_age); + publish_if_set(this->signal_strength_sensor_, device.get_rssi()); + + if (measurement_counter == this->last_measurement_counter_) + return true; + this->last_measurement_counter_ = measurement_counter; + + // Not meaningful for Aranet Radiation, which stores its dose duration in these bytes + const float temperature = encode_uint16(data[11], data[10]) / 20.0f; + const float pressure = encode_uint16(data[13], data[12]) / 10.0f; + switch (device_type) { + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: { + const uint16_t co2 = encode_uint16(data[9], data[8]); + publish_if_set(this->co2_sensor_, co2); + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + publish_if_set(this->humidity_sensor_, data[14]); + ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature, + data[14], pressure); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: { + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: { + const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]); + const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]); + const uint16_t radiation_rate = encode_uint16(data[15], data[14]); + publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f); + publish_if_set(this->radiation_total_sensor_, radiation_total); + publish_if_set(this->radiation_duration_sensor_, radiation_duration); + ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate, + radiation_total); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: { + const uint16_t radon = encode_uint16(data[9], data[8]); + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->radon_sensor_, radon); + // Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model. + if (temperature != 0) + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + if (humidity != 0) + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon, + temperature, humidity, pressure); + break; + } + default: + break; + } + publish_if_set(this->battery_level_sensor_, trailer[0]); + publish_if_set(this->measurement_interval_sensor_, measurement_interval); + + return true; + } + + return false; +} + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/aranet.h b/esphome/components/aranet/aranet.h new file mode 100644 index 00000000000..7064ad7d451 --- /dev/null +++ b/esphome/components/aranet/aranet.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::aranet { + +class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + explicit Aranet(uint64_t address) : address_(address) {} + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0; + static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1; + static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2; + + // Small state first so it fills the padding before the first pointer + uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement + uint8_t warned_{0}; + + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(pressure) + SUB_SENSOR(battery_level) + SUB_SENSOR(measurement_interval) + SUB_SENSOR(measurement_age) + SUB_SENSOR(signal_strength) + SUB_SENSOR(radon) + SUB_SENSOR(radiation_rate) + SUB_SENSOR(radiation_total) + SUB_SENSOR(radiation_duration) + + protected: + uint64_t address_; +}; + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py new file mode 100644 index 00000000000..f22d242f9cc --- /dev/null +++ b/esphome/components/aranet/sensor.py @@ -0,0 +1,150 @@ +import esphome.codegen as cg +from esphome.components import ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CO2, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_PRESSURE, + CONF_RADON, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RADIOACTIVE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_BECQUEREL_PER_CUBIC_METER, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_HECTOPASCAL, + UNIT_MICROSILVERTS_PER_HOUR, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, + UNIT_SECOND, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base"] + +CONF_MEASUREMENT_AGE = "measurement_age" +CONF_MEASUREMENT_INTERVAL = "measurement_interval" +CONF_RADIATION_RATE = "radiation_rate" +CONF_RADIATION_TOTAL = "radiation_total" +CONF_RADIATION_DURATION = "radiation_duration" +UNIT_NANOSIEVERT = "nSv" + +aranet_ns = cg.esphome_ns.namespace("aranet") +Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component) + +DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Aranet), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_HECTOPASCAL, + accuracy_decimals=1, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_RADON, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR, + accuracy_decimals=3, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_NANOSIEVERT, + accuracy_decimals=0, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor) + await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor) + await sensors(CONF_RADON, var.set_radon_sensor) + await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor) + await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor) + await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor) diff --git a/tests/components/aranet/common-ln.yaml b/tests/components/aranet/common-ln.yaml new file mode 100644 index 00000000000..1652ae5c6a3 --- /dev/null +++ b/tests/components/aranet/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature diff --git a/tests/components/aranet/common.h b/tests/components/aranet/common.h new file mode 100644 index 00000000000..41b5001a1f4 --- /dev/null +++ b/tests/components/aranet/common.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "esphome/components/aranet/aranet.h" + +namespace esphome::aranet::testing { + +constexpr uint8_t MAC_ADDRESS[6] = {0x55, 0x44, 0x33, 0x22, 0x11, 0x00}; +constexpr uint64_t ADDRESS = 0x001122334455ULL; + +inline const std::vector ARANET4_PAYLOAD = { + 0x20, // Smart Home integrations enabled + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, // CO2: 600 ppm + 0xA4, 0x01, // Temperature: 21.0 C (420 / 20) + 0x94, 0x27, // Pressure: 1013.2 hPa (10132 / 10) + 45, // Humidity: 45% + 87, // Battery: 87% + 0x00, // Display status + 0x2C, 0x01, // Measurement interval: 300 s + 12, 0x00, // Measurement age: 12 s + 7, // Measurement counter +}; + +inline const std::vector ARANET2_PAYLOAD = {0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x99, 0x01, 0x00, 0x00, 0x0A, 0x02, + 0x00, 0x3B, 0x09, 0x78, 0x00, 0x52, 0x00, 0x64}; + +inline const std::vector RADIATION_PAYLOAD = {0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0xD0, 0x33, + 0x00, 0x00, 0x6C, 0x60, 0x06, 0x00, 0x82, 0x00, + 0x00, 0x63, 0x00, 0x2C, 0x01, 0x58, 0x00, 0x72}; + +inline const std::vector RADON_PAYLOAD = {0x03, 0x21, 0x04, 0x06, 0x01, 0x00, 0x00, 0x00, + 0x07, 0x00, 0xFE, 0x01, 0xC9, 0x27, 0xCE, 0x01, + 0x00, 0x64, 0x01, 0x58, 0x02, 0xF6, 0x01, 0x08}; + +inline ble_device_base::ESPBTDevice make_device(const std::vector &payload, int rssi = -61, + const uint8_t *mac_address = MAC_ADDRESS) { + // Manufacturer specific data AD structure for company id 0x0702 + std::vector advertisement{static_cast(payload.size() + 3), 0xFF, 0x02, 0x07}; + advertisement.insert(advertisement.end(), payload.begin(), payload.end()); + + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac_address, rssi, 0, advertisement.data(), advertisement.size()); + return device; +} + +struct Harness { + Harness() { + this->aranet.set_co2_sensor(&this->co2); + this->aranet.set_temperature_sensor(&this->temperature); + this->aranet.set_humidity_sensor(&this->humidity); + this->aranet.set_pressure_sensor(&this->pressure); + this->aranet.set_battery_level_sensor(&this->battery); + this->aranet.set_measurement_interval_sensor(&this->interval); + this->aranet.set_measurement_age_sensor(&this->age); + this->aranet.set_signal_strength_sensor(&this->signal_strength); + this->aranet.set_radon_sensor(&this->radon); + this->aranet.set_radiation_rate_sensor(&this->radiation_rate); + this->aranet.set_radiation_total_sensor(&this->radiation_total); + this->aranet.set_radiation_duration_sensor(&this->radiation_duration); + } + + Aranet aranet{ADDRESS}; + sensor::Sensor co2, temperature, humidity, pressure, battery, interval, age, signal_strength, radon, radiation_rate, + radiation_total, radiation_duration; +}; + +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/common.yaml b/tests/components/aranet/common.yaml new file mode 100644 index 00000000000..09338269ea4 --- /dev/null +++ b/tests/components/aranet/common.yaml @@ -0,0 +1,27 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature + humidity: + name: Aranet4 Humidity + pressure: + name: Aranet4 Pressure + battery_level: + name: Aranet4 Battery Level + measurement_interval: + name: Aranet4 Measurement Interval + measurement_age: + name: Aranet4 Measurement Age + signal_strength: + name: Aranet4 Signal Strength + radon: + name: Aranet Radon Concentration + radiation_rate: + name: Aranet Radiation Dose Rate + radiation_total: + name: Aranet Radiation Total Dose + radiation_duration: + name: Aranet Radiation Dose Duration diff --git a/tests/components/aranet/sensor/test_aranet.cpp b/tests/components/aranet/sensor/test_aranet.cpp new file mode 100644 index 00000000000..deba1114e5d --- /dev/null +++ b/tests/components/aranet/sensor/test_aranet.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::aranet::testing { +namespace { + +TEST(Aranet, DecodesAranet4) { + Harness h; + auto device = make_device(ARANET4_PAYLOAD); + ASSERT_EQ(device.address_uint64(), ADDRESS); + + EXPECT_TRUE(h.aranet.parse_device(device)); + EXPECT_FLOAT_EQ(h.co2.state, 600.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 21.0f); + EXPECT_FLOAT_EQ(h.humidity.state, 45.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1013.2f); + EXPECT_FLOAT_EQ(h.battery.state, 87.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 12.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -61.0f); + EXPECT_FALSE(h.radon.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, UpdatesDiagnosticsForRepeatedMeasurement) { + Harness h; + int co2_updates = 0; + int age_updates = 0; + int signal_strength_updates = 0; + h.co2.add_on_state_callback([&co2_updates](float) { co2_updates++; }); + h.age.add_on_state_callback([&age_updates](float) { age_updates++; }); + h.signal_strength.add_on_state_callback([&signal_strength_updates](float) { signal_strength_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61))); + auto payload = ARANET4_PAYLOAD; + payload[19] = 18; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload, -54))); + + EXPECT_EQ(co2_updates, 1); + EXPECT_EQ(age_updates, 2); + EXPECT_EQ(signal_strength_updates, 2); + EXPECT_FLOAT_EQ(h.age.state, 18.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -54.0f); +} + +TEST(Aranet, RejectsDisabledIntegrationAndMalformedPayload) { + Harness h; + auto payload = ARANET4_PAYLOAD; + + payload[0] = 0; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + payload.pop_back(); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, RejectsMismatchedAddressAndUnsupportedDeviceType) { + Harness h; + constexpr uint8_t other_mac_address[6] = {0x66, 0x44, 0x33, 0x22, 0x11, 0x00}; + EXPECT_FALSE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61, other_mac_address))); + + auto payload = ARANET4_PAYLOAD; + payload.insert(payload.begin(), 0x09); + payload.push_back(0x00); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + // Type 0 is Aranet4, which never sends the 24 byte layout; the second warning stays quiet + payload[0] = 0x00; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, DecodesAranet2) { + Harness h; + int temperature_updates = 0; + h.temperature.add_on_state_callback([&temperature_updates](float) { temperature_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET2_PAYLOAD))); + EXPECT_NEAR(h.temperature.state, 20.45f, 0.001f); + EXPECT_FLOAT_EQ(h.humidity.state, 52.2f); + EXPECT_FLOAT_EQ(h.battery.state, 59.0f); + EXPECT_FLOAT_EQ(h.interval.state, 120.0f); + EXPECT_FLOAT_EQ(h.age.state, 82.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + + auto repeated_payload = ARANET2_PAYLOAD; + repeated_payload[10] = 0x00; + repeated_payload[11] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(repeated_payload))); + EXPECT_EQ(temperature_updates, 1); +} + +TEST(Aranet, DecodesAranetRadiation) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADIATION_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radiation_rate.state, 0.13f); + EXPECT_FLOAT_EQ(h.radiation_total.state, 13264.0f); + EXPECT_FLOAT_EQ(h.radiation_duration.state, 417900.0f); + EXPECT_FLOAT_EQ(h.battery.state, 99.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 88.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + EXPECT_FALSE(h.radon.has_state()); +} + +TEST(Aranet, DecodesAranetRadon) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADON_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 25.5f); + EXPECT_FLOAT_EQ(h.humidity.state, 46.2f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, RadonOneSkipsZeroTemperatureAndHumidity) { + Harness h; + auto payload = RADON_PAYLOAD; + payload[10] = payload[11] = 0x00; + payload[14] = payload[15] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); +} + +} // namespace +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/test.esp32-idf.yaml b/tests/components/aranet/test.esp32-idf.yaml new file mode 100644 index 00000000000..ca7354799f1 --- /dev/null +++ b/tests/components/aranet/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + aranet: !include common.yaml diff --git a/tests/components/aranet/test.ln882x-ard.yaml b/tests/components/aranet/test.ln882x-ard.yaml new file mode 100644 index 00000000000..9c195fc5678 --- /dev/null +++ b/tests/components/aranet/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + aranet: !include common-ln.yaml diff --git a/tests/components/aranet/validate.bk72xx-ard.yaml b/tests/components/aranet/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..97a2fe61b20 --- /dev/null +++ b/tests/components/aranet/validate.bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: aranet + ble_hub_id: ble_hub + mac_address: 00:11:22:33:44:55 + co2: + name: BK Aranet4 Carbon Dioxide From 4cae63bbaa8deed355e7d262c58a6e105ca865c2 Mon Sep 17 00:00:00 2001 From: gledian Date: Tue, 13 Oct 2026 02:27:16 +0200 Subject: [PATCH 03/66] [ecocomfort2] Add the read-only sensors and clock sync (Part 2) (#15447) Co-authored-by: gledian Co-authored-by: Claude Opus 4.6 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ecocomfort2/__init__.py | 7 +- .../components/ecocomfort2/binary_sensor.py | 33 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 233 +++++++++++++++--- .../components/ecocomfort2/ecocomfort2_hub.h | 60 +++++ esphome/components/ecocomfort2/sensor.py | 62 +++++ esphome/components/ecocomfort2/text_sensor.py | 24 ++ tests/components/ecocomfort2/common.yaml | 35 +++ 7 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 esphome/components/ecocomfort2/binary_sensor.py create mode 100644 esphome/components/ecocomfort2/sensor.py create mode 100644 esphome/components/ecocomfort2/text_sensor.py diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 472ac8f7cf8..8a0e1654188 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -1,12 +1,12 @@ import esphome.codegen as cg -from esphome.components import ble_client, esp32_ble +from esphome.components import ble_client, esp32_ble, time from esphome.components.esp32_ble import ( CONF_AUTH_REQ_MODE, CONF_IO_CAPABILITY, CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -27,6 +27,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) .extend(ble_client.BLE_CLIENT_SCHEMA) @@ -52,6 +53,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) + if (time_id := config.get(CONF_TIME_ID)) is not None: + cg.add(var.set_time(await cg.get_variable(time_id))) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/ecocomfort2/binary_sensor.py b/esphome/components/ecocomfort2/binary_sensor.py new file mode 100644 index 00000000000..15e5b69d2d7 --- /dev/null +++ b/esphome/components/ecocomfort2/binary_sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONF_BOOST = "boost" + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BOOST): binary_sensor.binary_sensor_schema(), + } + ), + cv.has_at_least_one_key(CONF_CONNECTED, CONF_BOOST), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_CONNECTED, hub.set_connected_binary_sensor) + await sensors(CONF_BOOST, hub.set_boost_binary_sensor) diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index a20fdd9112c..00ce0f20804 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -3,22 +3,39 @@ #include "ecocomfort2_hub.h" #include "esphome/core/log.h" +#include + namespace esphome::ecocomfort2 { ESPHOME_LOG_TAG(TAG, "ecocomfort2"); -static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); -static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); +void Ecocomfort2Hub::setup() { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_initial_state(false); +#endif +#ifdef USE_TIME + // The clock is written after each time-source sync + if (this->time_ != nullptr) + this->time_->add_on_time_sync_callback([this]() { this->sync_clock_(); }); +#endif +} void Ecocomfort2Hub::update() { if (!this->is_ready()) { ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); return; } - auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), - this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + // The GATT client runs the requests one after the other + this->read_char_(this->char_handle_state_); + this->read_char_(this->char_handle_oper_); +} + +void Ecocomfort2Hub::read_char_(uint16_t handle) { + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle, + ESP_GATT_AUTH_REQ_NONE); if (status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + ESP_LOGW(TAG, "[%s] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status); } } @@ -28,6 +45,22 @@ void Ecocomfort2Hub::dump_config() { " Address: %s", this->parent_->address_str()); LOG_UPDATE_INTERVAL(this); +#ifdef USE_TIME + ESP_LOGCONFIG(TAG, " Clock sync: %s", YESNO(this->time_ != nullptr)); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); + LOG_SENSOR(" ", "Direction", this->direction_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Connected", this->connected_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Boost", this->boost_binary_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Firmware", this->firmware_text_sensor_); +#endif } void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -39,17 +72,34 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if this->cancel_timeout(READBACK_TIMEOUT_ID); this->status_set_warning(); this->char_handle_oper_ = 0; + this->char_handle_state_ = 0; + this->char_handle_info_ = 0; + this->char_handle_clock_ = 0; + this->set_connected_(false); + this->publish_unknown_(); break; } case ESP_GATTC_SEARCH_CMPL_EVT: { - // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. - auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); - if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + const auto service_uuid = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); + const auto oper_uuid = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + const auto state_uuid = espbt::ESPBTUUID::from_raw("438d3433-7e5a-459a-a8e4-66343fad2bb0"); + const auto info_uuid = espbt::ESPBTUUID::from_raw("f5f56229-dd4f-480f-a829-9189269d8b37"); + const auto clock_uuid = espbt::ESPBTUUID::from_raw("82788997-49e4-4533-b949-7ed433678044"); + // Keep the handles, not the characteristics: ble_client frees its services once all nodes are established. + auto *oper = this->parent_->get_characteristic(service_uuid, oper_uuid); + auto *state = this->parent_->get_characteristic(service_uuid, state_uuid); + if (oper == nullptr || state == nullptr) { + ESP_LOGW(TAG, "[%s] C_OPER or C_STATE characteristic not found, disconnecting", this->parent_->address_str()); this->parent_->disconnect(); break; } - this->char_handle_oper_ = chr->handle; + this->char_handle_oper_ = oper->handle; + this->char_handle_state_ = state->handle; + // The version and clock characteristics only feed optional features, so a unit without them still works + auto *info = this->parent_->get_characteristic(service_uuid, info_uuid); + this->char_handle_info_ = info != nullptr ? info->handle : 0; + auto *clock = this->parent_->get_characteristic(service_uuid, clock_uuid); + this->char_handle_clock_ = clock != nullptr ? clock->handle : 0; this->node_state = espbt::ClientState::ESTABLISHED; this->status_set_warning(); @@ -71,31 +121,22 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if break; } case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + if (param->read.conn_id != this->parent_->get_conn_id()) { break; } - if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { - // ble_client reconnects and pairs again - ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); - this->parent_->disconnect(); + const uint16_t handle = param->read.handle; + if (handle == this->char_handle_oper_) { + if (this->read_ok_(param)) + this->on_oper_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_state_) { + if (this->read_ok_(param)) + this->on_state_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_info_) { + if (this->read_ok_(param)) + this->on_info_(param->read.value, param->read.value_len); + } else { break; } - if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); - break; - } - if (param->read.value_len < 2) { - ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); - break; - } - this->mode_ = param->read.value[0]; - this->speed_ = param->read.value[1]; - ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef ECOCOMFORT2_CHILD_COUNT - for (auto *child : this->children_) { - child->on_status(); - } -#endif break; } default: @@ -103,6 +144,21 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if } } +bool Ecocomfort2Hub::read_ok_(const esp_ble_gattc_cb_param_t *param) { + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + return false; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] Read of handle 0x%x failed, status=%d", this->parent_->address_str(), param->read.handle, + param->read.status); + return false; + } + return true; +} + void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || this->node_state != espbt::ClientState::ESTABLISHED) { @@ -122,9 +178,122 @@ void Ecocomfort2Hub::on_paired_() { this->cancel_timeout(AUTH_TIMEOUT_ID); this->status_clear_warning(); ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->set_connected_(true); +#ifdef USE_TIME + if (this->time_ != nullptr) + this->sync_clock_(); +#endif +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr && this->char_handle_info_ != 0) + this->read_char_(this->char_handle_info_); +#endif this->update(); } +void Ecocomfort2Hub::set_connected_(bool connected) { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_state(connected); +#endif +} + +// Readings from a unit that is out of reach would otherwise look current for hours +void Ecocomfort2Hub::publish_unknown_() { +#ifdef USE_SENSOR + for (auto *sensor : {this->temperature_sensor_, this->humidity_sensor_, this->voc_sensor_, this->direction_sensor_}) { + if (sensor != nullptr) + sensor->publish_state(NAN); + } +#endif +} + +// C_OPER: mode byte, speed/flags byte +void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), len); + return; + } + this->mode_ = data[0]; + this->speed_ = data[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); +#endif +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif +} + +// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { + if (len < 9) { + ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); + return; + } + const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float humidity = encode_uint16(data[5], data[6]) / 100.0f; + const uint16_t voc = encode_uint16(data[7], data[8]); + ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, + humidity, voc, data[2]); +#ifdef USE_SENSOR + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->direction_sensor_ != nullptr) + this->direction_sensor_->publish_state(data[2]); +#endif +} + +// C_INFO: firmware version packed as 4-bit major, 6-bit minor and 6-bit patch +void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_INFO too short: %u bytes", this->parent_->address_str(), len); + return; + } + const uint16_t field = encode_uint16(data[0] & 0x0F, data[1]); + char version[12]; + snprintf(version, sizeof(version), "%u.%u.%u", data[0] >> 4, field >> 6, field & 0x3F); + ESP_LOGD(TAG, "[%s] Firmware %s", this->parent_->address_str(), version); +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr) + this->firmware_text_sensor_->publish_state(version); +#endif +} + +#ifdef USE_TIME +// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +void Ecocomfort2Hub::sync_clock_() { + if (!this->is_ready() || this->char_handle_clock_ == 0) + return; + const ESPTime now = this->time_->now(); + if (!now.is_valid()) + return; + // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 + const uint8_t weekday = (now.day_of_week + 5) % 7; + uint8_t data[8]{now.hour, + now.minute, + now.second, + weekday, + now.day_of_month, + now.month, + static_cast(now.year >> 8), + static_cast(now.year)}; + ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_CLOCK write failed, status=%d", this->parent_->address_str(), status); + } +} +#endif + void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { if (!this->is_ready()) { ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); @@ -139,7 +308,7 @@ void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); return; } - this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->read_char_(this->char_handle_oper_); }); } #ifdef ECOCOMFORT2_CHILD_COUNT diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index 5a609d9283e..ed046fe0495 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -8,6 +8,19 @@ #include "esphome/core/helpers.h" #include "ecocomfort2_child.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_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + namespace esphome::ecocomfort2 { namespace espbt = esphome::esp32_ble_tracker; @@ -33,6 +46,7 @@ static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { public: + void setup() override; void update() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BLUETOOTH; } @@ -44,6 +58,23 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling #ifdef ECOCOMFORT2_CHILD_COUNT void register_child(Ecocomfort2Client *obj); #endif +#ifdef USE_SENSOR + void set_temperature_sensor(sensor::Sensor *s) { this->temperature_sensor_ = s; } + void set_humidity_sensor(sensor::Sensor *s) { this->humidity_sensor_ = s; } + void set_voc_sensor(sensor::Sensor *s) { this->voc_sensor_ = s; } + void set_direction_sensor(sensor::Sensor *s) { this->direction_sensor_ = s; } +#endif +#ifdef USE_BINARY_SENSOR + void set_connected_binary_sensor(binary_sensor::BinarySensor *s) { this->connected_binary_sensor_ = s; } + void set_boost_binary_sensor(binary_sensor::BinarySensor *s) { this->boost_binary_sensor_ = s; } +#endif +#ifdef USE_TEXT_SENSOR + void set_firmware_text_sensor(text_sensor::TextSensor *s) { this->firmware_text_sensor_ = s; } +#endif +#ifdef USE_TIME + /// Keeps the unit's clock in step with this time source while connected. + void set_time(time::RealTimeClock *time) { this->time_ = time; } +#endif /// True once the link is established and paired. bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } @@ -62,11 +93,40 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling static constexpr uint32_t READBACK_DELAY_MS = 300; void on_paired_(); + void set_connected_(bool connected); + void publish_unknown_(); + void read_char_(uint16_t handle); + bool read_ok_(const esp_ble_gattc_cb_param_t *param); + void on_oper_(const uint8_t *data, uint16_t len); + void on_state_(const uint8_t *data, uint16_t len); + void on_info_(const uint8_t *data, uint16_t len); +#ifdef USE_TIME + void sync_clock_(); +#endif #ifdef ECOCOMFORT2_CHILD_COUNT StaticVector children_; +#endif +#ifdef USE_SENSOR + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *voc_sensor_{nullptr}; + sensor::Sensor *direction_sensor_{nullptr}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *connected_binary_sensor_{nullptr}; + binary_sensor::BinarySensor *boost_binary_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *firmware_text_sensor_{nullptr}; +#endif +#ifdef USE_TIME + time::RealTimeClock *time_{nullptr}; #endif uint16_t char_handle_oper_{0}; + uint16_t char_handle_state_{0}; + uint16_t char_handle_info_{0}; + uint16_t char_handle_clock_{0}; uint8_t mode_{OPER_OFF}; uint8_t speed_{SPEED_OFF}; }; diff --git a/esphome/components/ecocomfort2/sensor.py b/esphome/components/ecocomfort2/sensor.py new file mode 100644 index 00000000000..878cc05f874 --- /dev/null +++ b/esphome/components/ecocomfort2/sensor.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_HUMIDITY, + CONF_TEMPERATURE, + CONF_VOC, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_BILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_VOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DIRECTION): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY, CONF_VOC, CONF_DIRECTION), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_VOC, hub.set_voc_sensor) + await sensors(CONF_DIRECTION, hub.set_direction_sensor) diff --git a/esphome/components/ecocomfort2/text_sensor.py b/esphome/components/ecocomfort2/text_sensor.py new file mode 100644 index 00000000000..4f2ac365399 --- /dev/null +++ b/esphome/components/ecocomfort2/text_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = text_sensor.sub_text_sensors(config) + await sensors(CONF_VERSION, hub.set_firmware_text_sensor) diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml index b4951e804f8..84fd33a983b 100644 --- a/tests/components/ecocomfort2/common.yaml +++ b/tests/components/ecocomfort2/common.yaml @@ -3,6 +3,14 @@ esp32_ble: auth_req_mode: sc_bond max_key_size: 16 +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + id: sntp_time + ble_client: - mac_address: 01:02:03:04:05:06 id: ecocomfort2_ble_1 @@ -12,6 +20,7 @@ ble_client: ecocomfort2: - id: ecocomfort2_hub_1 ble_client_id: ecocomfort2_ble_1 + time_id: sntp_time - id: ecocomfort2_hub_2 ble_client_id: ecocomfort2_ble_2 @@ -22,3 +31,29 @@ fan: - platform: ecocomfort2 name: Ecocomfort2 Fan 2 ecocomfort2_id: ecocomfort2_hub_2 + +sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + temperature: + name: Ecocomfort2 Temperature + humidity: + name: Ecocomfort2 Humidity + voc: + name: Ecocomfort2 VOC + direction: + name: Ecocomfort2 Direction + +binary_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + connected: + name: Ecocomfort2 Connected + boost: + name: Ecocomfort2 Boost + +text_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_2 + version: + name: Ecocomfort2 Firmware From dc9a7cffbdbb7490c791ef65fe9b362230af64f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:28:38 +0200 Subject: [PATCH 04/66] [ota] Refuse a second OTA while one is already running (#20588) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ota/ota_backend.cpp | 58 ++++++++++ esphome/components/ota/ota_backend.h | 24 ++++ .../ota/ota_backend_arduino_libretiny.cpp | 9 +- .../ota/ota_backend_arduino_libretiny.h | 1 + .../ota/ota_backend_arduino_rp2.cpp | 5 + .../components/ota/ota_backend_arduino_rp2.h | 1 + .../components/ota/ota_backend_esp8266.cpp | 5 + esphome/components/ota/ota_backend_esp8266.h | 1 + .../components/ota/ota_backend_esp_idf.cpp | 10 +- esphome/components/ota/ota_backend_esp_idf.h | 6 + esphome/components/ota/ota_backend_host.cpp | 5 + esphome/components/ota/ota_backend_host.h | 1 + .../web_server/ota/ota_web_server.cpp | 104 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 4 + .../web_server_idf/web_server_idf.h | 9 ++ esphome/espota2.py | 5 + tests/components/ota/test_update_lock.cpp | 85 ++++++++++++++ tests/unit_tests/test_espota2.py | 1 + 18 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 tests/components/ota/test_update_lock.cpp diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3b..a83195d42dc 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efef..840d1ac11c7 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -51,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -95,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 02a5c5750f8..33af42b6a7a 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -14,6 +14,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f23..fc277bb5e41 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -16,6 +16,7 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 74f44c0b3bc..d4d94be2363 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -16,6 +16,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index 15142869ac3..93b9dcb75ce 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -21,6 +21,7 @@ class ArduinoRP2OTABackend final { static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 5be55a52000..3d6148e64f6 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -51,6 +51,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 1f1ec37eee4..bd4c9a1bce7 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -23,6 +23,7 @@ class ESP8266OTABackend final { static constexpr bool supports_compression() { return true; } protected: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2316aad5cf6..fd6fd7f6f15 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,6 +20,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 4f4093a5945..d166bb361fa 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -36,6 +36,12 @@ class IDFOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 456fbb58638..923b0afe18a 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index e53868f102b..6c86fe10bab 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -22,6 +22,7 @@ class HostOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3cf1d9802ef..8a668ae0fc1 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) - if (Update.isRunning()) { - Update.abort(); - } -#endif -#endif // USE_ARDUINO - this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // is unknown (contentLength includes multipart overhead) // Pass 0 to indicate unknown size error_code = this->ota_backend_->begin(0); + if (error_code == ota::OTA_RESPONSE_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2153e232a05..d9a7d65e8ce 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -311,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f9094b05c50..4d4490d9076 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -183,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -198,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); diff --git a/esphome/espota2.py b/esphome/espota2.py index f775afe7f66..d8a2ff9000d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -54,6 +54,7 @@ RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94 +RESPONSE_ERROR_UPDATE_BUSY = 0x95 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -203,6 +204,10 @@ _ERROR_MESSAGES: dict[int, str] = { "of the YAML this upload uses, or update your esphome installation if it " "predates OTA encryption." ), + RESPONSE_ERROR_UPDATE_BUSY: ( + "The device is busy with another update that writes to its flash. " + "Wait for it to finish, then retry." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/components/ota/test_update_lock.cpp b/tests/components/ota/test_update_lock.cpp new file mode 100644 index 00000000000..28314cb1c27 --- /dev/null +++ b/tests/components/ota/test_update_lock.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/ota/ota_backend_factory.h" + +namespace esphome::ota::testing { + +TEST(UpdateLock, SecondHolderIsRefused) { + UpdateLock first; + UpdateLock second; + EXPECT_TRUE(first.try_acquire()); + EXPECT_FALSE(second.try_acquire()); + first.release(); + EXPECT_TRUE(second.try_acquire()); +} + +TEST(UpdateLock, AcquireIsIdempotentForTheHolder) { + UpdateLock lock; + EXPECT_TRUE(lock.try_acquire()); + EXPECT_TRUE(lock.try_acquire()); + lock.release(); + UpdateLock other; + EXPECT_TRUE(other.try_acquire()); +} + +TEST(UpdateLock, ReleaseByNonHolderKeepsLock) { + UpdateLock holder; + UpdateLock other; + EXPECT_TRUE(holder.try_acquire()); + other.release(); + EXPECT_FALSE(other.try_acquire()); +} + +TEST(UpdateLock, DestructorReleases) { + { + UpdateLock scoped; + EXPECT_TRUE(scoped.try_acquire()); + } + UpdateLock next; + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, IsHeldTracksOwnership) { + UpdateLock holder; + UpdateLock other; + EXPECT_FALSE(holder.is_held()); + EXPECT_TRUE(holder.try_acquire()); + EXPECT_TRUE(holder.is_held()); + EXPECT_FALSE(other.try_acquire()); + EXPECT_FALSE(other.is_held()); +} + +class TestUpdateLock : public UpdateLock { + public: + using UpdateLock::force_release; +}; + +TEST(UpdateLock, HoldUntilRebootOutlivesHolder) { + { + UpdateLock committed; + EXPECT_TRUE(committed.try_acquire()); + committed.hold_until_reboot(); + EXPECT_FALSE(committed.is_held()); + } + UpdateLock next; + EXPECT_FALSE(next.try_acquire()); + TestUpdateLock::force_release(); + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, SecondBackendBeginIsBusy) { + auto first = make_ota_backend(); + auto second = make_ota_backend(); + ASSERT_EQ(first->begin(1024), OTA_RESPONSE_OK); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_ERROR_UPDATE_BUSY); + // Native OTA calls abort() after a refused begin(). + second->abort(); + uint8_t data[16] = {}; + EXPECT_EQ(first->write(data, sizeof(data)), OTA_RESPONSE_OK); + first->abort(); + first.reset(); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_OK); + second->abort(); +} + +} // namespace esphome::ota::testing diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6fa6c616b67..f1e04da8c87 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -263,6 +263,7 @@ def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) - espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, "An error occurred while updating the bootloader", ), + (espota2.RESPONSE_ERROR_UPDATE_BUSY, "The device is busy with another update"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) From a3e373b063ff9a60e4b3f72d72fa1b7633c04615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:31:36 -1000 Subject: [PATCH 05/66] [noise] Add generate_encryption_key and use it in the wizard and dashboard_import (#20600) --- esphome/components/dashboard_import/__init__.py | 6 ++---- esphome/components/noise/__init__.py | 6 ++++++ esphome/wizard.py | 6 ++---- tests/component_tests/noise/test_encryption_key.py | 14 +++++++++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index aca0901d2b8..576887f6dc7 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -1,13 +1,12 @@ -import base64 from pathlib import Path import re -import secrets from typing import Any from ruamel.yaml import YAML from esphome import git import esphome.codegen as cg +from esphome.components.noise import generate_encryption_key from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI @@ -175,8 +174,7 @@ def import_config( "esphome": esphome_core, } if encryption: - noise_psk = secrets.token_bytes(32) - key = base64.b64encode(noise_psk).decode() + key = generate_encryption_key() config["api"] = {"encryption": {"key": key}} output = dump(config) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 1769c110184..781079c1901 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -1,5 +1,6 @@ import base64 import binascii +import secrets from typing import Any import esphome.codegen as cg @@ -22,6 +23,11 @@ noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) +def generate_encryption_key() -> str: + """A fresh random key in the base64 form the yaml takes.""" + return base64.b64encode(secrets.token_bytes(32)).decode() + + def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: diff --git a/esphome/wizard.py b/esphome/wizard.py index 897d5f60a14..10ea0d0b4cc 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,4 +1,3 @@ -import base64 from pathlib import Path import secrets import string @@ -7,6 +6,7 @@ import unicodedata import voluptuous as vol +from esphome.components.noise import generate_encryption_key import esphome.config_validation as cv from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD from esphome.core import CORE @@ -516,9 +516,7 @@ def wizard(path: Path) -> int: safe_print() sleep(0.5) - # Generate encryption key (32 bytes, base64 encoded) for secure API communication - noise_psk = secrets.token_bytes(32) - api_encryption_key = base64.b64encode(noise_psk).decode() + api_encryption_key = generate_encryption_key() safe_print( "For secure API communication, I've generated a random encryption key." diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py index 2b79bd5464f..3a895e25de6 100644 --- a/tests/component_tests/noise/test_encryption_key.py +++ b/tests/component_tests/noise/test_encryption_key.py @@ -5,7 +5,11 @@ from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.noise import decode_encryption_key, validate_encryption_key +from esphome.components.noise import ( + decode_encryption_key, + generate_encryption_key, + validate_encryption_key, +) KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @@ -42,3 +46,11 @@ def test_validate_encryption_key_rejects_all_zeros() -> None: key, so it never reaches a build.""" with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + +def test_generate_encryption_key_passes_the_validator() -> None: + """A fresh key is 32 random bytes in the base64 form the yaml takes.""" + key = generate_encryption_key() + assert validate_encryption_key(key) == key + assert len(decode_encryption_key(key)) == 32 + assert key != generate_encryption_key() From bdd2dcd8c6f5af6fa7a2938a7ca38798b6a43020 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:39:08 +1300 Subject: [PATCH 06/66] [weikai] Mark configurable classes as final (#17292) Co-authored-by: J. Nick Koston --- esphome/components/weikai/weikai.cpp | 4 ++-- esphome/components/weikai/weikai.h | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 2576856e2f6..938b191756d 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { void WeikaiChannel::setup_channel() { ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name()); // we enable transmit and receive on this channel - if (this->check_channel_down()) { + if (this->check_channel_down_()) { ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name()); } this->reset_fifo_(); @@ -378,7 +378,7 @@ size_t WeikaiChannel::rx_in_fifo_() { return available; } -bool WeikaiChannel::check_channel_down() { +bool WeikaiChannel::check_channel_down_() { // to check if we channel is up we write to the LCR W/R register // note that this will put a break on the tx line for few ms WeikaiRegister &lcr = this->reg(WKREG_LCR); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 02a39d3c848..eb8a41ad9d0 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -266,7 +266,7 @@ class WeikaiComponent : public Component { /////////////////////////////////////////////////////////////////////////////// /// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin. /////////////////////////////////////////////////////////////////////////////// -class WeikaiGPIOPin : public GPIOPin { +class WeikaiGPIOPin final : public GPIOPin { public: void set_parent(WeikaiComponent *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } @@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin { /// uart::UARTComponent virtual class. This class is common to the different members of the Weikai /// components family and therefore avoid code duplication. /////////////////////////////////////////////////////////////////////////////////////////////////// -class WeikaiChannel : public uart::UARTComponent { +class WeikaiChannel final : public uart::UARTComponent { public: /// @brief We belongs to this WeikaiComponent /// @param parent pointer to the component we belongs to @@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent { const char *get_channel_name() { return this->name_.c_str(); } /// @brief Setup the channel - void virtual setup_channel(); + void setup_channel(); /// @brief dump channel information - void virtual dump_channel(); + void dump_channel(); /// @brief Factory method to create a WeikaiRegister proxy object /// @param reg address of the register @@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent { /// @brief check if channel is alive /// @return true if OK - bool virtual check_channel_down(); + bool check_channel_down_(); #ifdef TEST_COMPONENT /// @ingroup test_ From f3f3ca5a8a7427f92889a5bb62ffeca6cf3cce33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:50:24 -1000 Subject: [PATCH 07/66] [runtime_image] Add CodSpeed benchmarks for JPEG decoding (#20603) --- .../components/runtime_image/__init__.py | 12 + .../runtime_image/bench_jpeg_decode.cpp | 64 ++++++ .../components/runtime_image/jpeg_96x96.h | 213 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 tests/benchmarks/components/runtime_image/__init__.py create mode 100644 tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp create mode 100644 tests/benchmarks/components/runtime_image/jpeg_96x96.h diff --git a/tests/benchmarks/components/runtime_image/__init__.py b/tests/benchmarks/components/runtime_image/__init__.py new file mode 100644 index 00000000000..775fda50c10 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/__init__.py @@ -0,0 +1,12 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in benchmark builds; the JPEG decoder is normally + # enabled by process_runtime_image_config(), so enable it here. + async def to_code_benchmark(config: ConfigType) -> None: + enable_format("JPEG") + + manifest.to_code = to_code_benchmark diff --git a/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp new file mode 100644 index 00000000000..900b340e9c2 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "esphome/components/runtime_image/runtime_image.h" +#include "jpeg_96x96.h" + +namespace esphome::benchmarks { + +using runtime_image::RuntimeImage; + +// A fixed-size image so every decode scales the same JPEG into the same buffer +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, int width, int height) + : RuntimeImage(runtime_image::JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {} +}; + +// One full decode session, fed the way online_image feeds a download +static bool decode(BenchImage &img, std::vector &buffer) { + if (!img.begin_decode(buffer.size(), runtime_image::JPEG)) { + return false; + } + size_t offset = 0; + while (offset < buffer.size()) { + const int consumed = img.feed_data(buffer.data() + offset, buffer.size() - offset); + if (consumed <= 0) { + return false; + } + offset += consumed; + } + return img.end_decode(); +} + +static void run(benchmark::State &state, image::ImageType type, int width, int height) { + BenchImage img(type, width, height); + std::vector buffer(JPEG_96X96, JPEG_96X96 + JPEG_96X96_SIZE); + for (auto _ : state) { + if (!decode(img, buffer)) { + state.SkipWithError("decode failed"); + return; + } + benchmark::DoNotOptimize(img.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * static_cast(width) * height); +} + +// --- JPEG into an RGB565 image: same size, half size and double size --- + +static void JpegDecode_Rgb565_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 96, 96); } +BENCHMARK(JpegDecode_Rgb565_Native); + +static void JpegDecode_Rgb565_Half(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 48, 48); } +BENCHMARK(JpegDecode_Rgb565_Half); + +static void JpegDecode_Rgb565_Double(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 192, 192); } +BENCHMARK(JpegDecode_Rgb565_Double); + +// --- JPEG into an RGB image at the same size, the generic per-pixel path for reference --- + +static void JpegDecode_Rgb_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB, 96, 96); } +BENCHMARK(JpegDecode_Rgb_Native); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/runtime_image/jpeg_96x96.h b/tests/benchmarks/components/runtime_image/jpeg_96x96.h new file mode 100644 index 00000000000..625515ec489 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/jpeg_96x96.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +namespace esphome::benchmarks { + +// 96x96 baseline JPEG, quality 85, no chroma subsampling: a gradient with rings, 3791 bytes +static const uint8_t JPEG_96X96[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x05, 0x03, 0x04, 0x04, 0x04, 0x03, 0x05, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, + 0x06, 0x07, 0x0C, 0x08, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x0B, 0x0B, 0x09, 0x0C, 0x11, 0x0F, 0x12, 0x12, 0x11, 0x0F, + 0x11, 0x11, 0x13, 0x16, 0x1C, 0x17, 0x13, 0x14, 0x1A, 0x15, 0x11, 0x11, 0x18, 0x21, 0x18, 0x1A, 0x1D, 0x1D, 0x1F, + 0x1F, 0x1F, 0x13, 0x17, 0x22, 0x24, 0x22, 0x1E, 0x24, 0x1C, 0x1E, 0x1F, 0x1E, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x05, + 0x05, 0x05, 0x07, 0x06, 0x07, 0x0E, 0x08, 0x08, 0x0E, 0x1E, 0x14, 0x11, 0x14, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x60, 0x00, 0x60, 0x03, 0x01, 0x11, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, 0x66, 0x0B, 0x6E, + 0x7A, 0x57, 0xED, 0x30, 0x8B, 0xB9, 0xE7, 0x53, 0xAA, 0x69, 0x5A, 0xDB, 0xF4, 0xE2, 0xBD, 0xEC, 0x12, 0x67, 0x5C, + 0x2A, 0x9A, 0x90, 0x41, 0x81, 0xD2, 0xBE, 0x96, 0x9C, 0xAD, 0x13, 0xAE, 0x9D, 0x40, 0xB8, 0x87, 0xE5, 0xE9, 0x5F, + 0x1B, 0xC4, 0x55, 0xD2, 0x83, 0x3D, 0x8C, 0x2C, 0xF5, 0x33, 0x9A, 0x0C, 0xC9, 0xD2, 0xBF, 0x01, 0xCD, 0x6A, 0xF3, + 0x57, 0x3E, 0x97, 0x0F, 0x53, 0x43, 0x77, 0x45, 0xB7, 0xE9, 0xC5, 0x7E, 0x99, 0xC1, 0x29, 0xE8, 0xCB, 0xA9, 0x54, + 0xD0, 0xD4, 0x21, 0xC4, 0x5D, 0x2B, 0xD5, 0xE3, 0x9A, 0xC9, 0x61, 0xDA, 0x3B, 0x70, 0x35, 0x35, 0x21, 0xD1, 0xAD, + 0xF3, 0x3F, 0x4E, 0xF5, 0xF9, 0x87, 0x06, 0x3E, 0x6C, 0x6D, 0xFC, 0xCF, 0x76, 0xA5, 0x4F, 0x74, 0xD8, 0xD5, 0x21, + 0xC4, 0x20, 0x62, 0xBE, 0xE7, 0xC4, 0x3A, 0xD6, 0xC2, 0x24, 0x6F, 0x80, 0xA9, 0xEF, 0x16, 0x3C, 0x3F, 0x6F, 0xF2, + 0x13, 0x8A, 0xF2, 0xFC, 0x39, 0x6D, 0x51, 0x93, 0x3D, 0x4A, 0xF5, 0x35, 0x40, 0xD0, 0x67, 0x52, 0x1C, 0x77, 0xAF, + 0x8F, 0xCC, 0xEA, 0x73, 0xF1, 0x0A, 0xB7, 0x73, 0xD8, 0xC3, 0xD4, 0xB5, 0x23, 0x53, 0x56, 0x83, 0xF7, 0x28, 0x31, + 0x5F, 0x47, 0xE2, 0x45, 0x4B, 0x61, 0x29, 0x47, 0xD0, 0xEA, 0xCB, 0xEA, 0x7B, 0xCC, 0xF2, 0xB8, 0x6D, 0x39, 0xE9, + 0x5F, 0xAD, 0xC3, 0x09, 0xA9, 0xFC, 0x5D, 0x4E, 0xB9, 0xA1, 0x6D, 0x6B, 0x8C, 0x71, 0x5E, 0xB6, 0x1E, 0x87, 0x29, + 0xD9, 0x0A, 0xC5, 0xE4, 0x87, 0x0B, 0xD2, 0xBA, 0x6B, 0xD6, 0x54, 0xE0, 0x77, 0xD1, 0xA9, 0x72, 0x0B, 0x94, 0xE2, + 0xBF, 0x2D, 0xE2, 0x7C, 0xC9, 0x28, 0xB5, 0x73, 0xDE, 0xC2, 0x48, 0xA9, 0x14, 0x1B, 0xA4, 0xE9, 0x5F, 0x8E, 0x4E, + 0xA3, 0xAF, 0x88, 0xD0, 0xFA, 0x0A, 0x55, 0x2C, 0x8E, 0x97, 0x48, 0xB5, 0xC2, 0x03, 0x8A, 0xFD, 0xBB, 0x84, 0x70, + 0xFE, 0xCE, 0x92, 0x93, 0x14, 0xEB, 0x5D, 0x8F, 0xD4, 0xE3, 0xE3, 0x15, 0xF3, 0xDC, 0x7F, 0x8F, 0x4A, 0x2E, 0x37, + 0x3D, 0x5C, 0x04, 0x89, 0x74, 0x2B, 0x5C, 0xC9, 0x9C, 0x57, 0x81, 0xC0, 0x14, 0x1C, 0xEB, 0x73, 0x9E, 0xB5, 0x6A, + 0xDA, 0x58, 0xBF, 0xAB, 0x45, 0xC0, 0x5C, 0x57, 0x77, 0x89, 0x38, 0xC5, 0xCA, 0xA9, 0xA3, 0xB7, 0x2F, 0x9F, 0x52, + 0xFE, 0x89, 0x6D, 0xB6, 0xD8, 0xB6, 0x3B, 0x57, 0xA1, 0xC0, 0xD0, 0xF6, 0x39, 0x74, 0xAA, 0x3E, 0xC7, 0x75, 0x4A, + 0xB7, 0x9D, 0x86, 0x5B, 0x41, 0xE6, 0x6A, 0x5D, 0x3B, 0xD7, 0xE7, 0xF8, 0x59, 0x7D, 0x6B, 0x88, 0xAE, 0xBB, 0x9E, + 0xDC, 0x2A, 0x72, 0xD1, 0x34, 0xB5, 0x68, 0x7E, 0x68, 0xD3, 0x15, 0xED, 0x78, 0x93, 0x88, 0xBD, 0x4A, 0x54, 0x57, + 0x91, 0xD9, 0x97, 0x4F, 0x46, 0xCF, 0x37, 0x8A, 0xD8, 0x7A, 0x57, 0xF4, 0x83, 0x84, 0x51, 0xFC, 0x4B, 0x4A, 0xA9, + 0x6A, 0x38, 0x40, 0x1D, 0x2B, 0x1A, 0xB8, 0x88, 0x53, 0x47, 0xA3, 0x4A, 0x6D, 0x8B, 0x20, 0x00, 0x57, 0xC7, 0xE7, + 0x19, 0xD4, 0x69, 0xC5, 0xEA, 0x7A, 0xF8, 0x67, 0x72, 0x94, 0xCB, 0xB8, 0xE2, 0xBF, 0x15, 0xCF, 0xB3, 0x77, 0x5A, + 0x4D, 0x26, 0x7D, 0x16, 0x19, 0xD8, 0xB5, 0xA7, 0x59, 0x97, 0x70, 0x71, 0x5C, 0xD9, 0x06, 0x06, 0x58, 0x8A, 0xC9, + 0xD8, 0xF4, 0xBD, 0xBD, 0x91, 0xD3, 0xDA, 0xDB, 0x08, 0xE1, 0xE9, 0xDA, 0xBF, 0x79, 0xC2, 0x46, 0x38, 0x2C, 0x25, + 0xDF, 0x62, 0x21, 0x57, 0x9A, 0x46, 0x7D, 0xE2, 0x79, 0x92, 0xED, 0x15, 0xF8, 0x3F, 0x17, 0x66, 0x4F, 0x15, 0x8A, + 0xE4, 0x4C, 0xFA, 0x3C, 0x1C, 0xB9, 0x63, 0x73, 0x6F, 0x45, 0xB3, 0xD9, 0x16, 0xE2, 0x2B, 0xF4, 0x3E, 0x09, 0xC1, + 0xFD, 0x5B, 0x0B, 0xED, 0x64, 0x6B, 0x52, 0xBD, 0xE5, 0x62, 0x2B, 0xE8, 0xFC, 0xCB, 0x90, 0xA0, 0x77, 0xAF, 0xCE, + 0x78, 0xCB, 0x1C, 0xF1, 0x99, 0x8A, 0xA7, 0x1E, 0xE7, 0xB9, 0x82, 0x97, 0x2C, 0x2E, 0x6E, 0x5B, 0xDB, 0x88, 0x6C, + 0x3A, 0x76, 0xAF, 0xD1, 0xE8, 0xC9, 0x65, 0xB9, 0x1B, 0x6F, 0x4D, 0x0D, 0x69, 0xD4, 0xE7, 0xAA, 0x33, 0x43, 0xB5, + 0xDF, 0x74, 0x5C, 0x8A, 0xFC, 0xF3, 0x81, 0x69, 0x3C, 0x56, 0x65, 0x3A, 0xEC, 0xF6, 0xEA, 0xD6, 0xE5, 0x82, 0x45, + 0xAB, 0xB8, 0x7C, 0xDD, 0x41, 0x10, 0x0E, 0x86, 0xB1, 0xE2, 0xBC, 0x47, 0xD7, 0xB3, 0xE8, 0x51, 0x5D, 0x19, 0xE8, + 0xE0, 0xE7, 0xCB, 0x4A, 0xE7, 0x98, 0xA3, 0x0C, 0x57, 0xEE, 0xB5, 0xB8, 0x92, 0x09, 0x6E, 0x7F, 0x14, 0xD2, 0x4C, + 0x79, 0x71, 0x5F, 0x33, 0x98, 0xF1, 0x44, 0x52, 0x76, 0x67, 0xAF, 0x42, 0x24, 0x4E, 0x4B, 0x57, 0xE7, 0x19, 0xAE, + 0x7F, 0x2A, 0xCD, 0xA4, 0xCF, 0x6F, 0x0F, 0xA1, 0x2D, 0xAD, 0xA3, 0x48, 0xC3, 0x8A, 0xF0, 0xB0, 0x98, 0x5A, 0xB8, + 0xCA, 0xA7, 0xAB, 0x0A, 0xCA, 0x28, 0xE8, 0xF4, 0xBD, 0x3F, 0x62, 0x82, 0x56, 0xBF, 0x68, 0xE1, 0xAC, 0x95, 0x61, + 0xE0, 0xA7, 0x24, 0x0F, 0x13, 0x72, 0xDD, 0xE6, 0x11, 0x36, 0x8A, 0x8E, 0x2D, 0xCF, 0x61, 0x85, 0xA2, 0xE1, 0x16, + 0x7A, 0x18, 0x3F, 0x79, 0xDC, 0xA9, 0x65, 0x6A, 0xD3, 0xCE, 0x0E, 0x3B, 0xD7, 0xE3, 0x59, 0x56, 0x1E, 0x79, 0x9E, + 0x3B, 0x99, 0xF7, 0x3E, 0x85, 0x56, 0x50, 0x89, 0xD2, 0x79, 0x22, 0xDE, 0xD7, 0x18, 0xC1, 0xC5, 0x7E, 0xD7, 0x98, + 0x62, 0x61, 0x94, 0xE5, 0xB6, 0xD9, 0xD8, 0x54, 0x27, 0xED, 0x26, 0x53, 0xD3, 0xED, 0x4C, 0xF7, 0x7B, 0x88, 0xE3, + 0x35, 0xF8, 0xC6, 0x47, 0x46, 0x79, 0xAE, 0x6B, 0xED, 0x1E, 0xD7, 0x3E, 0x87, 0xDB, 0x72, 0x42, 0xC6, 0xC6, 0xA6, + 0x9B, 0x21, 0x11, 0x8A, 0xFB, 0x6E, 0x3F, 0xCC, 0xD6, 0x17, 0x07, 0x1C, 0x34, 0x1E, 0xA6, 0xF8, 0x07, 0xCD, 0x2B, + 0x96, 0xB4, 0x6B, 0x5F, 0x2A, 0xD4, 0xC8, 0x47, 0x6A, 0xCF, 0x82, 0xF0, 0xEB, 0x01, 0x96, 0x4F, 0x15, 0x3E, 0xA8, + 0xF4, 0x6A, 0x56, 0xE7, 0x9A, 0x8A, 0x1D, 0xA5, 0xDB, 0x19, 0xF5, 0x03, 0x21, 0x1C, 0x03, 0x5F, 0x25, 0xC3, 0x31, + 0x79, 0x9E, 0x7B, 0x3C, 0x44, 0xB6, 0x4C, 0xF6, 0x1D, 0x5E, 0x4A, 0x49, 0x1E, 0x2D, 0x1A, 0xB9, 0xA9, 0x9E, 0x6F, + 0x5E, 0x47, 0xF1, 0xE5, 0x26, 0x8B, 0x11, 0x40, 0xED, 0xDA, 0xB8, 0xE5, 0x53, 0x11, 0x5D, 0x9E, 0x9D, 0x2A, 0x89, + 0x1A, 0x36, 0x9A, 0x73, 0x39, 0x19, 0x5A, 0xF4, 0xF0, 0x19, 0x15, 0x6C, 0x44, 0x93, 0x68, 0xED, 0x8E, 0x25, 0x23, + 0x7B, 0x4F, 0xD3, 0x42, 0x80, 0x48, 0xAF, 0xD5, 0x32, 0x4E, 0x1C, 0x85, 0x04, 0xA5, 0x34, 0x57, 0xD6, 0xAE, 0x68, + 0x48, 0x16, 0x14, 0xC0, 0xC5, 0x7A, 0x99, 0xCE, 0x75, 0x47, 0x2F, 0xA2, 0xE3, 0x16, 0x76, 0xE1, 0xAF, 0x36, 0x67, + 0xBA, 0x34, 0xF2, 0xE0, 0x7A, 0xD7, 0xE0, 0x79, 0xB6, 0x69, 0x57, 0x35, 0xC4, 0xF2, 0xC4, 0xFA, 0x5C, 0x3C, 0x94, + 0x11, 0xBD, 0xA4, 0x69, 0xE2, 0x24, 0x0E, 0xC2, 0xBF, 0x51, 0xE1, 0x2C, 0x96, 0x38, 0x2A, 0x3E, 0xDA, 0xA2, 0x2E, + 0x78, 0x9E, 0x67, 0x64, 0x1A, 0x81, 0xDE, 0xFE, 0x5A, 0xD7, 0xC6, 0xF1, 0xCF, 0x10, 0x3C, 0x55, 0x5F, 0xAB, 0xD2, + 0x67, 0xB1, 0x81, 0xF7, 0x55, 0xD9, 0xA7, 0xA3, 0xD8, 0x88, 0x61, 0xF3, 0x18, 0x76, 0xAF, 0xA2, 0xE0, 0xDC, 0xB2, + 0x39, 0x76, 0x11, 0xE2, 0x6A, 0xEE, 0x75, 0xD4, 0xC4, 0x73, 0xCA, 0xC8, 0x63, 0xC4, 0x6E, 0xAF, 0x42, 0x81, 0x90, + 0x0D, 0x7E, 0x7D, 0x9B, 0x62, 0xA7, 0x9E, 0xE7, 0x0A, 0x9C, 0x75, 0x49, 0x9E, 0xDE, 0x1E, 0x6A, 0x95, 0x3B, 0x9A, + 0xF7, 0x91, 0x88, 0x2D, 0x16, 0x25, 0x1C, 0x91, 0x5F, 0x67, 0xC5, 0xF8, 0xF8, 0xE5, 0x39, 0x54, 0x70, 0xB4, 0xF7, + 0x68, 0xDF, 0x07, 0x2F, 0x69, 0x3E, 0x66, 0x59, 0xD2, 0x2D, 0x44, 0x16, 0x6D, 0x33, 0x0C, 0x12, 0x2B, 0x8B, 0x84, + 0xA8, 0x2C, 0xAF, 0x29, 0xA9, 0x8C, 0xA9, 0xA3, 0x68, 0xF4, 0xE7, 0x5B, 0x9E, 0xA2, 0x8A, 0x3C, 0x86, 0x1D, 0x23, + 0xDA, 0xBE, 0xBA, 0x1C, 0x16, 0xAF, 0xAA, 0x3F, 0x8D, 0x69, 0xE3, 0x4D, 0x0B, 0x6D, 0x29, 0x46, 0x32, 0x2B, 0xD6, + 0xC2, 0xF0, 0x95, 0x2A, 0x7A, 0xC9, 0x1D, 0x90, 0xC6, 0x36, 0x69, 0xC1, 0x69, 0x1C, 0x63, 0xA0, 0xAF, 0x76, 0x18, + 0x6C, 0x26, 0x0A, 0x37, 0x76, 0x3B, 0x29, 0xD6, 0x94, 0x87, 0x4B, 0x2A, 0xA0, 0xC2, 0xD7, 0xCC, 0x67, 0x7C, 0x5F, + 0x47, 0x0B, 0x07, 0x18, 0x33, 0xD6, 0xC3, 0x41, 0xC9, 0xEA, 0x54, 0x22, 0x49, 0xDF, 0x00, 0x1A, 0xFC, 0x67, 0x32, + 0xCE, 0x31, 0x39, 0xAD, 0x5E, 0x58, 0x1F, 0x43, 0x87, 0x71, 0x82, 0x36, 0x34, 0x9D, 0x2F, 0x18, 0x77, 0x15, 0xF7, + 0x5C, 0x29, 0xC2, 0xDC, 0x96, 0xAD, 0x59, 0x1B, 0xCB, 0x17, 0x7D, 0x11, 0xA1, 0x76, 0xEB, 0x14, 0x7B, 0x12, 0xBD, + 0x1E, 0x2D, 0xE2, 0x6A, 0x58, 0x0A, 0x0E, 0x85, 0x17, 0xA9, 0xDB, 0x83, 0x8B, 0x93, 0xBB, 0x19, 0xA5, 0x58, 0x34, + 0xF3, 0x79, 0x8C, 0x38, 0xAF, 0xCF, 0xB8, 0x5F, 0x27, 0xAB, 0x9A, 0xE2, 0xFE, 0xB1, 0x57, 0x63, 0xDB, 0x96, 0x25, + 0x42, 0x36, 0x46, 0xAD, 0xF9, 0x11, 0x44, 0x22, 0x4E, 0xB5, 0xF5, 0xBC, 0x6B, 0x9F, 0xC3, 0x01, 0x86, 0x58, 0x4A, + 0x0F, 0x5D, 0x8E, 0x9C, 0x17, 0xBC, 0xF9, 0x99, 0x63, 0x44, 0xB1, 0xD8, 0xA6, 0x79, 0x05, 0x79, 0x9C, 0x0F, 0x94, + 0xAC, 0x3D, 0x29, 0x63, 0xF1, 0x1E, 0xA7, 0xA7, 0x53, 0x11, 0x77, 0xCA, 0x87, 0x98, 0x5A, 0xF2, 0xF8, 0x28, 0x19, + 0x50, 0x6B, 0xE6, 0x73, 0x1C, 0x4D, 0x4E, 0x23, 0xCE, 0x95, 0x38, 0xEB, 0x14, 0xCF, 0x5E, 0x84, 0xD5, 0x1A, 0x77, + 0x34, 0x75, 0x04, 0x09, 0x12, 0x5B, 0x20, 0xE4, 0xD7, 0xB7, 0xC7, 0x39, 0x8A, 0xC2, 0xE1, 0xA9, 0xE5, 0x98, 0x7D, + 0xDE, 0x87, 0x66, 0x06, 0x5C, 0xD2, 0xE7, 0x67, 0x94, 0xC7, 0x72, 0x9E, 0xD5, 0xFA, 0x85, 0x5E, 0x3A, 0xC3, 0xA5, + 0xA3, 0x3F, 0x8B, 0xE9, 0x52, 0x64, 0x9F, 0x6A, 0x1D, 0xAB, 0xE7, 0xF1, 0xBE, 0x20, 0xC5, 0x2F, 0x75, 0x9E, 0xAD, + 0x0A, 0x22, 0x19, 0xA4, 0x7E, 0x00, 0x35, 0xF0, 0xD9, 0x8F, 0x18, 0x62, 0xB1, 0x4D, 0xA8, 0x1E, 0xD5, 0x08, 0x46, + 0x3B, 0x93, 0x5B, 0x58, 0xCB, 0x33, 0x0C, 0x83, 0x5E, 0x56, 0x1B, 0x2B, 0xC7, 0x66, 0x75, 0x2F, 0x2B, 0x9E, 0x9C, + 0x31, 0x11, 0x82, 0x37, 0xB4, 0xFD, 0x29, 0x23, 0x01, 0x9C, 0x0A, 0xFD, 0x47, 0x23, 0xE1, 0x3A, 0x38, 0x28, 0xAA, + 0x95, 0x8D, 0x3E, 0xB8, 0xE5, 0xA2, 0x2D, 0x5C, 0x4A, 0x91, 0x2E, 0xC4, 0xEB, 0x58, 0xF1, 0x27, 0x17, 0xD0, 0xC0, + 0x52, 0x74, 0x68, 0x3D, 0x4F, 0x47, 0x09, 0x07, 0x27, 0x76, 0x43, 0x67, 0x65, 0x25, 0xD4, 0xBB, 0x98, 0x71, 0x5F, + 0x98, 0x65, 0xB9, 0x7E, 0x2F, 0x3F, 0xC5, 0xFB, 0x4A, 0x9B, 0x1E, 0xEC, 0x6B, 0xC6, 0x94, 0x6C, 0x8D, 0xFF, 0x00, + 0x2E, 0x3B, 0x3B, 0x7C, 0x0C, 0x67, 0x15, 0xFA, 0x96, 0x3B, 0x1B, 0x85, 0xE1, 0xBC, 0x0F, 0x24, 0x7E, 0x2B, 0x17, + 0x42, 0x52, 0xAD, 0x2B, 0x95, 0xEC, 0x6C, 0xDE, 0xF2, 0xE7, 0x7B, 0x0F, 0x96, 0xBF, 0x2D, 0xCA, 0x30, 0x58, 0x8E, + 0x22, 0xCC, 0x3D, 0xBD, 0x5F, 0x86, 0xE7, 0xBA, 0xAB, 0xAA, 0x50, 0xB2, 0x35, 0x6F, 0xB1, 0x14, 0x42, 0x08, 0xC7, + 0x3D, 0x2B, 0xEA, 0x38, 0xCF, 0x3E, 0x86, 0x03, 0x0C, 0xB2, 0xFC, 0x2E, 0xEF, 0x4D, 0x0E, 0xAC, 0x15, 0xE4, 0xF9, + 0xE4, 0x5B, 0xD2, 0xEC, 0xD6, 0xDA, 0xD8, 0xCF, 0x20, 0xF9, 0x88, 0xA8, 0xE1, 0x8C, 0x0C, 0x32, 0x4C, 0xBE, 0x58, + 0xFC, 0x4F, 0xC4, 0xD1, 0xE8, 0xCA, 0xBB, 0xA9, 0x35, 0x14, 0x3F, 0x4E, 0xB5, 0x37, 0x77, 0x86, 0x67, 0x1F, 0x2A, + 0x9A, 0xF9, 0xBC, 0x86, 0x85, 0x4C, 0xFF, 0x00, 0x36, 0x96, 0x3A, 0xB7, 0xC1, 0x16, 0x7A, 0xDE, 0xD9, 0x52, 0x82, + 0x8A, 0x3C, 0x76, 0x1D, 0x26, 0x53, 0xD8, 0xD7, 0xB8, 0xB8, 0x37, 0x1D, 0x27, 0xAD, 0xCF, 0xE3, 0x7A, 0x78, 0xD8, + 0x97, 0xED, 0xB4, 0x57, 0x38, 0xC8, 0xAF, 0x43, 0x0F, 0xC0, 0x55, 0xA6, 0xFD, 0xF3, 0xBA, 0x18, 0xF4, 0x6A, 0xDA, + 0x69, 0x11, 0xA6, 0x0B, 0x0A, 0xFA, 0x9C, 0x17, 0x05, 0xE1, 0x70, 0xDE, 0xF5, 0x43, 0xAA, 0x18, 0xD6, 0xF6, 0x34, + 0x11, 0x20, 0x81, 0x78, 0x03, 0x35, 0xE9, 0xD7, 0xCC, 0x72, 0xDC, 0xA6, 0x1A, 0x5A, 0xE8, 0xED, 0xA3, 0x29, 0xD4, + 0x64, 0x53, 0x5C, 0xB3, 0x9D, 0xB1, 0x8A, 0xFC, 0xCF, 0x3E, 0xE3, 0xAA, 0xB8, 0xA6, 0xE9, 0x61, 0xCF, 0x73, 0x0B, + 0x49, 0x47, 0x56, 0x4D, 0x61, 0xA6, 0x4B, 0x3B, 0x86, 0x90, 0x1C, 0x57, 0x95, 0x92, 0xF0, 0xCE, 0x2F, 0x35, 0xAA, + 0xAA, 0xE2, 0x36, 0x3D, 0x4F, 0xAD, 0xC6, 0x0A, 0xC8, 0xDB, 0x11, 0xC3, 0x67, 0x16, 0x06, 0x33, 0x5F, 0xA2, 0x63, + 0x33, 0x0C, 0x0F, 0x0D, 0xE1, 0x79, 0x61, 0x6E, 0x63, 0x5A, 0x12, 0x95, 0x69, 0x6A, 0x57, 0x86, 0xDE, 0x5B, 0xE9, + 0xFA, 0x1D, 0xB9, 0xAF, 0xCA, 0x23, 0xF5, 0xDE, 0x28, 0xC6, 0xDD, 0xDF, 0x92, 0xE7, 0xBF, 0x4E, 0xAC, 0x68, 0x44, + 0xDB, 0x31, 0xC7, 0x63, 0x6F, 0xB5, 0x47, 0xCD, 0x8A, 0xFD, 0x07, 0x30, 0xC7, 0xE1, 0x38, 0x5F, 0x01, 0xEC, 0xA9, + 0xFC, 0x6D, 0x1A, 0xD0, 0x94, 0xAB, 0xCA, 0xEC, 0x4D, 0x2E, 0xC1, 0xAE, 0x26, 0xF3, 0xE5, 0x07, 0x68, 0xE7, 0x9A, + 0xF8, 0xAE, 0x19, 0xCA, 0x2A, 0xE6, 0xF8, 0xB7, 0x98, 0x62, 0xFE, 0x15, 0xAE, 0xA7, 0xB2, 0xF1, 0x0A, 0x9C, 0x79, + 0x22, 0x5C, 0xBB, 0x06, 0xE2, 0x65, 0xB7, 0x84, 0x7C, 0xA3, 0x83, 0x8A, 0x5C, 0x57, 0x9C, 0xCF, 0x37, 0xC5, 0x47, + 0x2D, 0xC1, 0xFC, 0x2B, 0x4D, 0x0F, 0x43, 0x06, 0xD5, 0x38, 0xF3, 0xC8, 0xD0, 0x78, 0x96, 0xCA, 0xD0, 0x44, 0x83, + 0xE7, 0x6A, 0xF4, 0xF3, 0x7C, 0x5D, 0x2E, 0x19, 0xCA, 0x56, 0x12, 0x8F, 0xF1, 0x24, 0x8E, 0xDC, 0x3C, 0xDD, 0x6A, + 0x9C, 0xCF, 0x63, 0xC8, 0xE3, 0xBB, 0x8B, 0xB0, 0x15, 0xF7, 0xB5, 0x3C, 0x44, 0xC2, 0x25, 0xA2, 0x3F, 0x8C, 0xE9, + 0x50, 0x91, 0x30, 0xBD, 0x1F, 0xC2, 0xB5, 0xE2, 0xE2, 0xFC, 0x4A, 0x5B, 0x53, 0x47, 0xA9, 0x47, 0x0F, 0xDC, 0x70, + 0x9E, 0x69, 0x38, 0x55, 0x35, 0xF2, 0x78, 0xCE, 0x34, 0xCC, 0x71, 0x8F, 0x96, 0x92, 0x67, 0xB1, 0x42, 0x9C, 0x23, + 0xB9, 0x62, 0xDB, 0x4F, 0xB8, 0x9C, 0x82, 0xC0, 0xE2, 0xB8, 0xB0, 0xF9, 0x26, 0x6B, 0x9A, 0xCE, 0xF5, 0x2F, 0x63, + 0xD2, 0x86, 0x26, 0x10, 0xD8, 0xDA, 0xB2, 0xD2, 0x62, 0x84, 0x06, 0x93, 0x15, 0xF7, 0xB9, 0x5F, 0x07, 0xE1, 0x32, + 0xE8, 0xFB, 0x5C, 0x4B, 0xD4, 0xD9, 0x63, 0x65, 0x3D, 0x22, 0x59, 0x9A, 0x78, 0xE2, 0x5D, 0x91, 0x01, 0x9F, 0x6A, + 0xE4, 0xCF, 0x78, 0xDF, 0x0D, 0x80, 0x83, 0xA1, 0x83, 0xDF, 0xC8, 0xF4, 0xB0, 0xD4, 0xE5, 0x27, 0x79, 0x0D, 0xB4, + 0xB1, 0x9A, 0xF2, 0x4D, 0xCE, 0x0E, 0xDA, 0xF8, 0x5C, 0x06, 0x53, 0x98, 0x71, 0x16, 0x23, 0xDA, 0xD7, 0xBF, 0x29, + 0xED, 0x47, 0x11, 0x0A, 0x4A, 0xC8, 0xD9, 0xD9, 0x0D, 0x8C, 0x3B, 0x54, 0x0D, 0xD5, 0xF7, 0x78, 0xDC, 0xCB, 0x01, + 0xC2, 0xF8, 0x4F, 0x67, 0x4A, 0xCE, 0x66, 0xF4, 0x25, 0x3A, 0xF2, 0xBB, 0x1B, 0x65, 0x65, 0x2D, 0xEC, 0xDE, 0x64, + 0x83, 0x09, 0x5F, 0x03, 0x96, 0x65, 0xB8, 0xCE, 0x25, 0xC6, 0x7D, 0x67, 0x13, 0xA4, 0x11, 0xED, 0x2A, 0xF1, 0xA3, + 0x1B, 0x2D, 0xCD, 0x2B, 0x96, 0x58, 0x90, 0x5B, 0xC0, 0x39, 0xE9, 0xC5, 0x7B, 0x7C, 0x51, 0xC4, 0x54, 0xF0, 0x34, + 0x56, 0x5B, 0x97, 0xFC, 0x4F, 0x4D, 0x0E, 0xCC, 0x22, 0x72, 0x7C, 0xF3, 0x2D, 0xE9, 0xF6, 0x49, 0x69, 0x01, 0x9E, + 0x60, 0x37, 0x9F, 0x5A, 0x59, 0x1E, 0x5F, 0x47, 0x87, 0xB0, 0x52, 0xCC, 0x31, 0x9F, 0x1B, 0xD5, 0x5C, 0xF4, 0x3D, + 0xBB, 0xAB, 0x2E, 0x48, 0xEC, 0x4B, 0x61, 0x68, 0xD7, 0x97, 0x26, 0xE2, 0x51, 0x88, 0xD6, 0xBC, 0x1C, 0x9F, 0x0D, + 0x57, 0x88, 0xB3, 0x09, 0x66, 0x38, 0xBD, 0x29, 0xC7, 0x54, 0x7A, 0xCA, 0xB2, 0xA3, 0x1E, 0x48, 0xEE, 0x79, 0x3C, + 0x3A, 0x1A, 0x77, 0x35, 0xFA, 0x4A, 0xF0, 0xEA, 0x8A, 0xDE, 0x47, 0xF1, 0x85, 0x3C, 0xC1, 0x97, 0x60, 0xD1, 0xE0, + 0x5F, 0xBC, 0x45, 0x75, 0x52, 0xE0, 0x8C, 0xBA, 0x8E, 0xB5, 0x24, 0x8E, 0xEA, 0x78, 0xE9, 0xBD, 0x8B, 0xD0, 0xDA, + 0xDA, 0x42, 0x3B, 0x57, 0x57, 0xB3, 0xC8, 0xF2, 0xD5, 0x76, 0xD6, 0x87, 0x7D, 0x2A, 0xB5, 0x66, 0x4A, 0x6E, 0xA2, + 0x41, 0x88, 0xD6, 0xBC, 0x1C, 0xC7, 0xC4, 0x1C, 0x1E, 0x15, 0x38, 0x61, 0xA3, 0xA9, 0xEA, 0xD0, 0xA3, 0x29, 0x7C, + 0x43, 0x55, 0x6E, 0xAE, 0x9B, 0x0A, 0x08, 0x15, 0xF0, 0xB8, 0x9C, 0xDF, 0x38, 0xCF, 0x67, 0xCB, 0x4D, 0x34, 0x99, + 0xEC, 0xD1, 0x74, 0xE9, 0x2D, 0x4D, 0x4B, 0x1D, 0x1D, 0x53, 0x12, 0x4E, 0x7F, 0x3A, 0xFA, 0x4C, 0xA3, 0x82, 0x69, + 0x61, 0xD7, 0xD6, 0x31, 0xF2, 0xFB, 0xCE, 0xB5, 0x8E, 0x6F, 0x48, 0x97, 0x25, 0x9D, 0x22, 0x5F, 0x2A, 0x05, 0xC9, + 0xF6, 0xA3, 0x3C, 0xE3, 0x4C, 0x36, 0x02, 0x1F, 0x55, 0xCB, 0xD5, 0xDE, 0xDA, 0x1E, 0x86, 0x1A, 0x9B, 0x93, 0xE6, + 0x99, 0x25, 0x86, 0x99, 0x25, 0xC3, 0xF9, 0xB3, 0xE4, 0x2F, 0x5E, 0x6B, 0xE7, 0x72, 0x7E, 0x1B, 0xC5, 0xE6, 0xF5, + 0x7E, 0xB9, 0x8F, 0x76, 0x8E, 0xFA, 0x9E, 0xB2, 0xC5, 0xC6, 0x9A, 0xE5, 0x89, 0xA1, 0x3C, 0x8B, 0x12, 0x88, 0x2D, + 0xD7, 0x9E, 0x9C, 0x57, 0xA7, 0xC4, 0x1C, 0x55, 0x47, 0x03, 0x4F, 0xFB, 0x3F, 0x2C, 0x57, 0x96, 0xDA, 0x1D, 0xB8, + 0x58, 0xB9, 0x3E, 0x79, 0x96, 0x74, 0xED, 0x3D, 0x61, 0x5F, 0xB4, 0xDC, 0xF5, 0xEB, 0xCD, 0x73, 0x64, 0x39, 0x1D, + 0x3C, 0xBA, 0x9B, 0xCC, 0xF3, 0x37, 0xEF, 0x6F, 0xA9, 0xE9, 0x7D, 0x65, 0xCD, 0xF2, 0x40, 0x99, 0x21, 0x93, 0x50, + 0xB8, 0x00, 0x02, 0x22, 0x5A, 0xF1, 0x2B, 0xD7, 0xC5, 0xF1, 0x86, 0x61, 0xC9, 0x0D, 0x28, 0xC7, 0xEE, 0x3D, 0x4A, + 0x35, 0x23, 0x87, 0x85, 0xFA, 0x97, 0xA7, 0x50, 0x8A, 0xB6, 0x76, 0xC3, 0x93, 0xC1, 0x22, 0xBB, 0x38, 0x8B, 0x37, + 0x8E, 0x1E, 0x9C, 0x72, 0x5C, 0xAF, 0x77, 0xA3, 0x68, 0xEF, 0xC2, 0xBB, 0xBF, 0x69, 0x33, 0xC6, 0xA2, 0x5B, 0xD3, + 0xFC, 0x26, 0xBD, 0x19, 0x66, 0xBC, 0x43, 0x3D, 0x2C, 0xCF, 0xE3, 0x9A, 0x4E, 0x92, 0x2D, 0x45, 0x69, 0x7B, 0x27, + 0x63, 0x58, 0xBC, 0x37, 0x11, 0x62, 0xF4, 0x77, 0x3D, 0x1A, 0x55, 0xA9, 0x44, 0xBD, 0x6F, 0xA3, 0xCE, 0xFC, 0xB9, + 0x35, 0xD3, 0x43, 0x81, 0xF3, 0x2C, 0x53, 0xBD, 0x79, 0x9D, 0xF4, 0xF1, 0xD0, 0x8E, 0xC6, 0x9D, 0xB6, 0x93, 0x6F, + 0x16, 0x0C, 0x84, 0x1A, 0xFA, 0x0C, 0x37, 0x06, 0xE5, 0x99, 0x7A, 0xE7, 0xC4, 0xCD, 0x36, 0x75, 0xC3, 0x1B, 0x39, + 0x7C, 0x25, 0xBF, 0x3A, 0xDA, 0x01, 0xB6, 0x25, 0x04, 0xD4, 0x63, 0xB8, 0xC3, 0x2A, 0xCA, 0x63, 0xC9, 0x84, 0x8A, + 0x6C, 0xF4, 0x68, 0x42, 0x73, 0xD6, 0x42, 0x22, 0x5D, 0x5E, 0x36, 0x15, 0x48, 0x5A, 0xF8, 0x9A, 0xF9, 0x96, 0x75, + 0xC4, 0x75, 0x39, 0x29, 0xA6, 0xA2, 0xCF, 0x66, 0x8C, 0xA9, 0xD1, 0x5A, 0x9A, 0x96, 0x7A, 0x64, 0x36, 0xCB, 0xE6, + 0x4E, 0x41, 0x6A, 0xFA, 0x4C, 0xBF, 0x86, 0xB2, 0xFC, 0x92, 0x1F, 0x59, 0xC7, 0xC9, 0x39, 0x1D, 0x51, 0xC5, 0xCE, + 0xA3, 0xB4, 0x09, 0x1E, 0x59, 0x2E, 0x1B, 0xC9, 0xB7, 0x4C, 0x2F, 0x4E, 0x2B, 0xE7, 0xF3, 0x7E, 0x2C, 0xC5, 0xE6, + 0xF3, 0xFA, 0x9E, 0x59, 0x1B, 0x47, 0x6D, 0x0F, 0x5B, 0x0F, 0x08, 0xD3, 0x5C, 0xD3, 0x34, 0x2C, 0xEC, 0x22, 0xB4, + 0x4F, 0x3A, 0x72, 0x0B, 0xD7, 0xA5, 0x97, 0x64, 0xB8, 0x2E, 0x1E, 0xA3, 0xF5, 0xDC, 0xC2, 0x57, 0xA9, 0xBE, 0xA7, + 0x6C, 0x71, 0x32, 0xAA, 0xF9, 0x61, 0xB1, 0x22, 0x43, 0x36, 0xA1, 0x36, 0x00, 0x2B, 0x10, 0xAF, 0x9D, 0xAD, 0x88, + 0xC7, 0xF1, 0x86, 0x2F, 0x92, 0x1E, 0xED, 0x15, 0xF9, 0x1E, 0xB5, 0x19, 0xC7, 0x0F, 0x1D, 0x77, 0x2F, 0xB8, 0x4B, + 0x68, 0xC5, 0xB5, 0xB0, 0xCB, 0x9E, 0x09, 0x15, 0xE8, 0xE6, 0xF9, 0xCD, 0x0C, 0x92, 0x82, 0xCB, 0x32, 0xC5, 0x7A, + 0x8F, 0x46, 0xD1, 0xDB, 0x87, 0xBD, 0x47, 0xCF, 0x3D, 0x8B, 0x56, 0x96, 0x89, 0x65, 0x0F, 0x9F, 0x37, 0x32, 0xB7, + 0x41, 0x58, 0x60, 0x30, 0xD4, 0x38, 0x67, 0x08, 0xF3, 0x0C, 0x73, 0xBD, 0x69, 0x6C, 0x8F, 0x4A, 0x35, 0x9D, 0x69, + 0x72, 0x47, 0x63, 0xC8, 0xE3, 0xBD, 0x8F, 0xB4, 0x5F, 0xA5, 0x7D, 0xB4, 0xFC, 0x4A, 0xC2, 0x2F, 0x86, 0x97, 0xE0, + 0x7F, 0x19, 0xD2, 0xA1, 0x2E, 0xE4, 0xE9, 0x7A, 0xC7, 0xEE, 0x45, 0xFA, 0x57, 0x9F, 0x5B, 0xC4, 0xBA, 0x8F, 0x4A, + 0x34, 0xBF, 0x03, 0xD3, 0xA3, 0x41, 0x75, 0x64, 0xE9, 0x25, 0xE4, 0xBC, 0x24, 0x64, 0x57, 0x91, 0x5B, 0x8B, 0x33, + 0xEC, 0x7F, 0xBB, 0x46, 0x0D, 0x1E, 0xA5, 0x18, 0xD2, 0x8E, 0xEC, 0xB5, 0x06, 0x99, 0x79, 0x39, 0xCC, 0x84, 0x81, + 0x58, 0xD3, 0xE1, 0xBC, 0xF7, 0x34, 0x77, 0xC4, 0x4D, 0xA4, 0x7A, 0x34, 0xF1, 0x54, 0xA1, 0xB1, 0xA5, 0x6D, 0xA5, + 0x5B, 0x40, 0x37, 0x4C, 0xC0, 0x9A, 0xF7, 0x30, 0xFC, 0x27, 0x94, 0xE5, 0x4B, 0xDA, 0x63, 0x2A, 0x26, 0xD1, 0xD9, + 0x0C, 0x64, 0xE7, 0xA4, 0x4B, 0x26, 0xE2, 0x38, 0xC6, 0xCB, 0x68, 0xF2, 0x7D, 0x85, 0x71, 0x66, 0x1C, 0x77, 0x86, + 0xC2, 0xAF, 0x61, 0x96, 0x53, 0xBB, 0xF2, 0x3D, 0x2A, 0x14, 0x9C, 0xB5, 0x9B, 0x25, 0xB6, 0xD3, 0xEE, 0x6E, 0xDB, + 0x7C, 0xC4, 0xAA, 0xD7, 0x87, 0x87, 0xC8, 0xB3, 0x6C, 0xFE, 0xA7, 0xB6, 0xC7, 0x4B, 0x96, 0x07, 0xAB, 0x0C, 0x4D, + 0x3A, 0x4A, 0xD1, 0xDC, 0xD1, 0x51, 0x6D, 0x64, 0x9B, 0x22, 0x50, 0xCF, 0x5E, 0xC6, 0x2B, 0x38, 0xCA, 0x78, 0x66, + 0x97, 0xB1, 0xC2, 0x25, 0x2A, 0x87, 0x65, 0x17, 0x3A, 0xCE, 0xF2, 0xD8, 0x92, 0xD6, 0xC2, 0x7B, 0xC7, 0xF3, 0x6E, + 0x32, 0xA9, 0xE9, 0x5F, 0x3F, 0x84, 0xCA, 0x33, 0x0E, 0x22, 0xAB, 0xF5, 0xBC, 0xC6, 0x5C, 0xB4, 0xD6, 0xB6, 0x67, + 0xAD, 0x0C, 0x44, 0x28, 0xAE, 0x58, 0x6E, 0x5E, 0x66, 0x08, 0x05, 0xB5, 0x9A, 0x64, 0xF4, 0x24, 0x57, 0x46, 0x6B, + 0xC4, 0x94, 0xF0, 0xF1, 0xFE, 0xCB, 0xC9, 0x63, 0x77, 0xB3, 0x68, 0xEF, 0xC3, 0xA6, 0xFD, 0xFA, 0x8C, 0xB7, 0x6B, + 0x67, 0x1D, 0x94, 0x7E, 0x74, 0xFF, 0x00, 0x34, 0xA7, 0xA0, 0xA5, 0x84, 0xC1, 0x61, 0x38, 0x66, 0x83, 0xC7, 0x66, + 0x0F, 0x9A, 0xB3, 0xD9, 0x1E, 0x84, 0x2B, 0xCA, 0xB3, 0xE5, 0x86, 0xC5, 0x8B, 0x4B, 0x47, 0xBA, 0x93, 0xED, 0x37, + 0x3F, 0x2C, 0x63, 0x90, 0x0D, 0x79, 0xB8, 0x2C, 0x25, 0x7C, 0xF6, 0xBB, 0xCD, 0x33, 0x47, 0xCB, 0x46, 0x3A, 0xA4, + 0xCF, 0x4E, 0x35, 0xD5, 0x25, 0xC9, 0x0D, 0xCF, 0xFF, 0xD9, +}; +static constexpr size_t JPEG_96X96_SIZE = sizeof(JPEG_96X96); + +} // namespace esphome::benchmarks From 39a7f4482ae097afbb039ca6bb4a950a0cd02197 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:52:16 -1000 Subject: [PATCH 08/66] [bh1745] Use SUB_SENSOR for the sensors (#20606) --- esphome/components/bh1745/bh1745.h | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h index 06d0987d67d..6463aab38d0 100644 --- a/esphome/components/bh1745/bh1745.h +++ b/esphome/components/bh1745/bh1745.h @@ -22,6 +22,13 @@ enum class AdcGain : uint8_t { }; class BH1745Component : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(red_counts) + SUB_SENSOR(green_counts) + SUB_SENSOR(blue_counts) + SUB_SENSOR(clear_counts) + SUB_SENSOR(illuminance) + SUB_SENSOR(color_temperature) + public: void setup() override; void dump_config() override; @@ -31,25 +38,11 @@ class BH1745Component : public PollingComponent, public i2c::I2CDevice { void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } - void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; } - void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; } - void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; } - void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; } - void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; } - void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; } - /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. void enable_interrupt_output() { this->interrupt_output_ = true; } void set_interrupt_state(bool on); protected: - sensor::Sensor *red_counts_sensor_{nullptr}; - sensor::Sensor *green_counts_sensor_{nullptr}; - sensor::Sensor *blue_counts_sensor_{nullptr}; - sensor::Sensor *clear_counts_sensor_{nullptr}; - sensor::Sensor *illuminance_sensor_{nullptr}; - sensor::Sensor *color_temperature_sensor_{nullptr}; - float glass_attenuation_factor_{1.0f}; float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; From c3c235dea7c2f46611789d507db3bd4231b3924c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:56:58 -1000 Subject: [PATCH 09/66] [pzem6l24] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20609) --- esphome/components/pzem6l24/pzem6l24.cpp | 140 +++++++++++----------- esphome/components/pzem6l24/pzem6l24.h | 141 +++++++---------------- esphome/components/pzem6l24/sensor.py | 42 ++++++- 3 files changed, 150 insertions(+), 173 deletions(-) diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp index 41524eebd27..57331a639c7 100644 --- a/esphome/components/pzem6l24/pzem6l24.cpp +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -172,52 +172,52 @@ void PZEM6L24::publish_(const uint8_t *data) { // every sensor from flash. static constexpr SensorEntry SENSORS[] PROGMEM = { // Voltages (×0.1 V) - {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_a_sensor_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_sensor_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_sensor_, 4, REG_U16, SCALE_DECI}, // Currents (×0.01 A) - {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_a_sensor_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_sensor_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_sensor_, 10, REG_U16, SCALE_CENTI}, // Frequency (×0.01 Hz) - {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + {&PZEM6L24::frequency_sensor_, 12, REG_U16, SCALE_CENTI}, // Active powers (×0.1 W, signed) - {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_a_sensor_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_sensor_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_sensor_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_sensor_, 64, REG_I32, SCALE_DECI}, // Reactive powers (×0.1 var, signed) - {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_a_sensor_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_sensor_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_sensor_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_sensor_, 68, REG_I32, SCALE_DECI}, // Apparent powers (×0.1 VA, signed) - {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_a_sensor_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_sensor_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_sensor_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_sensor_, 72, REG_I32, SCALE_DECI}, // Power factors (×0.01), packed two per register: // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C - {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, - {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_a_sensor_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_sensor_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_sensor_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_sensor_, 78, REG_U8, SCALE_CENTI}, // Active energies (×0.1 kWh, unsigned) - {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_a_sensor_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_sensor_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_sensor_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_sensor_, 116, REG_U32, SCALE_DECI}, // Reactive energies (×0.1 kvarh, unsigned) - {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_a_sensor_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_sensor_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_sensor_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_sensor_, 120, REG_U32, SCALE_DECI}, // Apparent energies (×0.1 kVAh, unsigned) - {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_a_sensor_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_sensor_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_sensor_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_sensor_, 124, REG_U32, SCALE_DECI}, }; for (const SensorEntry &flash_entry : SENSORS) { @@ -268,41 +268,41 @@ void PZEM6L24::dump_config() { " Address: 0x%02X", this->address_); LOG_UPDATE_INTERVAL(this); - LOG_SENSOR(" ", "Voltage A", this->voltage_a_); - LOG_SENSOR(" ", "Voltage B", this->voltage_b_); - LOG_SENSOR(" ", "Voltage C", this->voltage_c_); - LOG_SENSOR(" ", "Current A", this->current_a_); - LOG_SENSOR(" ", "Current B", this->current_b_); - LOG_SENSOR(" ", "Current C", this->current_c_); - LOG_SENSOR(" ", "Active Power A", this->active_power_a_); - LOG_SENSOR(" ", "Active Power B", this->active_power_b_); - LOG_SENSOR(" ", "Active Power C", this->active_power_c_); - LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); - LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); - LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); - LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); - LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); - LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); - LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); - LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); - LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); - LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); - LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); - LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); - LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); - LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); - LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); - LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); - LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); - LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); - LOG_SENSOR(" ", "Frequency", this->frequency_); - LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); - LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); - LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); - LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); - LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); - LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); - LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_sensor_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_sensor_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_sensor_); + LOG_SENSOR(" ", "Current A", this->current_a_sensor_); + LOG_SENSOR(" ", "Current B", this->current_b_sensor_); + LOG_SENSOR(" ", "Current C", this->current_c_sensor_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_sensor_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_sensor_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_sensor_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_sensor_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_sensor_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_sensor_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_sensor_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_sensor_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_sensor_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_sensor_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_sensor_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_sensor_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_sensor_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_sensor_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_sensor_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_sensor_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_sensor_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_sensor_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_sensor_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_sensor_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_sensor_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_sensor_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_sensor_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_sensor_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_sensor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_sensor_); } // Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h index bf13f25053e..9d8351f6f76 100644 --- a/esphome/components/pzem6l24/pzem6l24.h +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -36,54 +36,51 @@ constexpr std::array build_reset_pdu(ResetPhase phase) { } class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + protected: + // Ahead of the sensor pointers so the two bytes share the base class's tail padding. + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + public: - // Per-phase sensors; each setter is named after its config key - void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } - void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } - void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } - - void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } - void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } - void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } - - void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } - void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } - void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } - - void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } - void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } - void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } - - void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } - void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } - void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } - - void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } - void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } - void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } - - void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } - void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } - void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } - - void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } - void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } - void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } - - void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } - void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } - void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } - - // Combined sensors - void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } - void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } - void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } - void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } - void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } - void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } - void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } - void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + SUB_SENSOR(voltage_a) + SUB_SENSOR(voltage_b) + SUB_SENSOR(voltage_c) + SUB_SENSOR(current_a) + SUB_SENSOR(current_b) + SUB_SENSOR(current_c) + SUB_SENSOR(active_power_a) + SUB_SENSOR(active_power_b) + SUB_SENSOR(active_power_c) + SUB_SENSOR(reactive_power_a) + SUB_SENSOR(reactive_power_b) + SUB_SENSOR(reactive_power_c) + SUB_SENSOR(apparent_power_a) + SUB_SENSOR(apparent_power_b) + SUB_SENSOR(apparent_power_c) + SUB_SENSOR(power_factor_a) + SUB_SENSOR(power_factor_b) + SUB_SENSOR(power_factor_c) + SUB_SENSOR(active_energy_a) + SUB_SENSOR(active_energy_b) + SUB_SENSOR(active_energy_c) + SUB_SENSOR(reactive_energy_a) + SUB_SENSOR(reactive_energy_b) + SUB_SENSOR(reactive_energy_c) + SUB_SENSOR(apparent_energy_a) + SUB_SENSOR(apparent_energy_b) + SUB_SENSOR(apparent_energy_c) + SUB_SENSOR(frequency) + SUB_SENSOR(total_active_power) + SUB_SENSOR(total_reactive_power) + SUB_SENSOR(total_apparent_power) + SUB_SENSOR(total_power_factor) + SUB_SENSOR(total_active_energy) + SUB_SENSOR(total_reactive_energy) + SUB_SENSOR(total_apparent_energy) + public: // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. void reset_energy(ResetPhase phase_option); @@ -104,58 +101,6 @@ class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevic void request_failed_(std::span request_pdu); void read_failed_(); void read_finished_(); - - // Register reads accepted by the hub but not yet resolved by a terminal callback. - uint8_t reads_outstanding_{0}; - // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. - uint8_t consecutive_failures_{0}; - - // Per-phase sensors - sensor::Sensor *voltage_a_{nullptr}; - sensor::Sensor *voltage_b_{nullptr}; - sensor::Sensor *voltage_c_{nullptr}; - - sensor::Sensor *current_a_{nullptr}; - sensor::Sensor *current_b_{nullptr}; - sensor::Sensor *current_c_{nullptr}; - - sensor::Sensor *active_power_a_{nullptr}; - sensor::Sensor *active_power_b_{nullptr}; - sensor::Sensor *active_power_c_{nullptr}; - - sensor::Sensor *reactive_power_a_{nullptr}; - sensor::Sensor *reactive_power_b_{nullptr}; - sensor::Sensor *reactive_power_c_{nullptr}; - - sensor::Sensor *apparent_power_a_{nullptr}; - sensor::Sensor *apparent_power_b_{nullptr}; - sensor::Sensor *apparent_power_c_{nullptr}; - - sensor::Sensor *power_factor_a_{nullptr}; - sensor::Sensor *power_factor_b_{nullptr}; - sensor::Sensor *power_factor_c_{nullptr}; - - sensor::Sensor *active_energy_a_{nullptr}; - sensor::Sensor *active_energy_b_{nullptr}; - sensor::Sensor *active_energy_c_{nullptr}; - - sensor::Sensor *reactive_energy_a_{nullptr}; - sensor::Sensor *reactive_energy_b_{nullptr}; - sensor::Sensor *reactive_energy_c_{nullptr}; - - sensor::Sensor *apparent_energy_a_{nullptr}; - sensor::Sensor *apparent_energy_b_{nullptr}; - sensor::Sensor *apparent_energy_c_{nullptr}; - - // Combined sensors - sensor::Sensor *frequency_{nullptr}; - sensor::Sensor *total_active_power_{nullptr}; - sensor::Sensor *total_reactive_power_{nullptr}; - sensor::Sensor *total_apparent_power_{nullptr}; - sensor::Sensor *total_power_factor_{nullptr}; - sensor::Sensor *total_active_energy_{nullptr}; - sensor::Sensor *total_reactive_energy_{nullptr}; - sensor::Sensor *total_apparent_energy_{nullptr}; }; } // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index fd0430d85be..5e883acd7e1 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -144,7 +144,7 @@ _COMBINED_QUANTITIES = { CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, } -# Every sensor by config key; the C++ setter is "set__sensor". +# Every sensor by config key; each needs its sensors(...) call in to_code _SENSORS = { f"{name}_{phase}": schema for name, schema in _PHASE_QUANTITIES.items() @@ -192,7 +192,39 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for key in _SENSORS: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors("voltage_a", var.set_voltage_a_sensor) + await sensors("voltage_b", var.set_voltage_b_sensor) + await sensors("voltage_c", var.set_voltage_c_sensor) + await sensors("current_a", var.set_current_a_sensor) + await sensors("current_b", var.set_current_b_sensor) + await sensors("current_c", var.set_current_c_sensor) + await sensors("active_power_a", var.set_active_power_a_sensor) + await sensors("active_power_b", var.set_active_power_b_sensor) + await sensors("active_power_c", var.set_active_power_c_sensor) + await sensors("reactive_power_a", var.set_reactive_power_a_sensor) + await sensors("reactive_power_b", var.set_reactive_power_b_sensor) + await sensors("reactive_power_c", var.set_reactive_power_c_sensor) + await sensors("apparent_power_a", var.set_apparent_power_a_sensor) + await sensors("apparent_power_b", var.set_apparent_power_b_sensor) + await sensors("apparent_power_c", var.set_apparent_power_c_sensor) + await sensors("power_factor_a", var.set_power_factor_a_sensor) + await sensors("power_factor_b", var.set_power_factor_b_sensor) + await sensors("power_factor_c", var.set_power_factor_c_sensor) + await sensors("active_energy_a", var.set_active_energy_a_sensor) + await sensors("active_energy_b", var.set_active_energy_b_sensor) + await sensors("active_energy_c", var.set_active_energy_c_sensor) + await sensors("reactive_energy_a", var.set_reactive_energy_a_sensor) + await sensors("reactive_energy_b", var.set_reactive_energy_b_sensor) + await sensors("reactive_energy_c", var.set_reactive_energy_c_sensor) + await sensors("apparent_energy_a", var.set_apparent_energy_a_sensor) + await sensors("apparent_energy_b", var.set_apparent_energy_b_sensor) + await sensors("apparent_energy_c", var.set_apparent_energy_c_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TOTAL_ACTIVE_POWER, var.set_total_active_power_sensor) + await sensors(CONF_TOTAL_REACTIVE_POWER, var.set_total_reactive_power_sensor) + await sensors(CONF_TOTAL_APPARENT_POWER, var.set_total_apparent_power_sensor) + await sensors(CONF_TOTAL_POWER_FACTOR, var.set_total_power_factor_sensor) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_TOTAL_APPARENT_ENERGY, var.set_total_apparent_energy_sensor) From afedddafe2f81821c946f04b28545655d658d763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:57:01 -1000 Subject: [PATCH 10/66] [systa_bus] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20610) --- .../components/systa_bus/sensor/__init__.py | 13 ++++++++----- .../systa_bus/sensor/systa_bus_sensor.h | 18 ++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index e4aa0684c78..d3223484ffc 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -43,7 +43,7 @@ _PUMP_SPEED_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) -# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +# Sensors per model, keyed by config key MODEL_SENSORS = { CONF_SYSTASOLAR_AQUA: { CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, @@ -77,10 +77,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - for key in MODEL_SENSORS[config[CONF_MODEL]]: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + # The schema only lets a model's own keys through, so unconfigured ones are skipped here + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE_TSA, var.set_temperature_tsa_sensor) + await sensors(CONF_TEMPERATURE_TSE, var.set_temperature_tse_sensor) + await sensors(CONF_TEMPERATURE_TWU, var.set_temperature_twu_sensor) + await sensors(CONF_TEMPERATURE_TW2, var.set_temperature_tw2_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h index 3688176d006..1b135c9666f 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.h +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -8,21 +8,15 @@ namespace esphome::systa_bus { class SystaSolarAquaSensor : public SystaBusListener, public Component { + SUB_SENSOR(temperature_tsa) + SUB_SENSOR(temperature_tse) + SUB_SENSOR(temperature_twu) + SUB_SENSOR(temperature_tw2) + SUB_SENSOR(pump_speed) + public: void dump_config() override; - void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } - void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } - void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } - void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } - void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } void handle_message(std::span message) override; - - protected: - sensor::Sensor *temperature_tsa_sensor_{nullptr}; - sensor::Sensor *temperature_tse_sensor_{nullptr}; - sensor::Sensor *temperature_twu_sensor_{nullptr}; - sensor::Sensor *temperature_tw2_sensor_{nullptr}; - sensor::Sensor *pump_speed_sensor_{nullptr}; }; } // namespace esphome::systa_bus From 58188305c4e3237120630762c8815b62515d3493 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:09:32 +0200 Subject: [PATCH 11/66] [uart] Keep the swapped UART0 pins on an ESP8266 reload (#20463) --- esphome/components/uart/uart_component_esp8266.cpp | 8 ++++++++ esphome/components/uart/uart_component_esp8266.h | 3 ++- tests/components/uart/test.esp8266-ard.yaml | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0819a1bfb00..9316e90eda1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -24,6 +24,8 @@ static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; __attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool ESP8266UartComponent::serial0_swapped = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + uint32_t ESP8266UartComponent::get_config() { uint32_t config = 0; @@ -111,6 +113,7 @@ void ESP8266UartComponent::setup() { this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); this->hw_serial_->swap(); ESP8266UartComponent::serial0_in_use = true; + ESP8266UartComponent::serial0_swapped = true; } else #endif // USE_ESP8266_UART_SERIAL #ifdef USE_ESP8266_UART_SERIAL1 @@ -133,6 +136,11 @@ void ESP8266UartComponent::load_settings(bool dump_config) { SerialConfig config = static_cast(get_config()); this->hw_serial_->begin(this->baud_rate_, config); this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); +#ifdef USE_ESP8266_UART_SERIAL + // begin() puts UART0 back on GPIO1/GPIO3, so swap it again like setup() did for GPIO15/GPIO13 + if (this->hw_serial_ == &Serial && ESP8266UartComponent::serial0_swapped) + this->hw_serial_->swap(); +#endif // USE_ESP8266_UART_SERIAL } else { this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, this->rx_buffer_size_); diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 3d4a2ba85bc..a29373a9d4b 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -92,7 +92,8 @@ class ESP8266UartComponent final : public UARTComponent, public Component { ESP8266SoftwareSerial *sw_serial_{nullptr}; private: - static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_swapped; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; } // namespace esphome::uart diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 4c9d6fd7367..09d23e2925e 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -7,6 +7,8 @@ esphome: - uart.write: id: uart_id data: [0x00, 0x20, 0x42] + - lambda: |- + id(uart_swapped).load_settings(false); uart: - id: uart_id @@ -23,6 +25,10 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_swapped + tx_pin: 15 + rx_pin: 13 + baud_rate: 9600 switch: - platform: uart From 54f228766cc0ed6bd86340fbf903eb0b44f33a99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:12:39 -1000 Subject: [PATCH 12/66] [stcc4] Use SUB_SENSOR for the output sensors (#20605) --- esphome/components/stcc4/stcc4.h | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h index ad25130ec73..1b8e02acccc 100644 --- a/esphome/components/stcc4/stcc4.h +++ b/esphome/components/stcc4/stcc4.h @@ -12,14 +12,15 @@ enum class MeasurementMode : uint8_t { }; class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; } - void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } - void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } void set_ambient_pressure_compensation(float pressure_in_hpa); @@ -36,9 +37,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); - sensor::Sensor *co2_sensor_{nullptr}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_source_{nullptr}; sensor::Sensor *humidity_source_{nullptr}; sensor::Sensor *ambient_pressure_source_{nullptr}; From e7a7aa150dab1f645520bf7807f477a9e8400384 Mon Sep 17 00:00:00 2001 From: Gunni Date: Tue, 13 Oct 2026 01:36:57 +0000 Subject: [PATCH 13/66] [yaml_util] Raise a clear error for a non-mapping `defaults:` in a package (#19425) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 15 ++++++- tests/unit_tests/test_substitutions.py | 58 ++++++++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 23 ++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f02819a3edb..b5e2ec17e8a 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -191,10 +191,23 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: If the value is already tagged, the new context vars are merged with existing ones, with new vars taking precedence. Returns the value tagged with ConfigContext. Returns the original value if value is not a list/string/dict. + + Raises EsphomeError if a `defaults:` key is present but is not a mapping. """ if isinstance(value, dict) and CONF_DEFAULTS in value: + defaults = value.pop(CONF_DEFAULTS) + if defaults is None: + raise EsphomeError( + f"'{CONF_DEFAULTS}' is empty; " + f"remove the key or give it at least one value" + ) + if not isinstance(defaults, dict): + raise EsphomeError( + f"'{CONF_DEFAULTS}' must be a key to value mapping, " + f"got {type(defaults)}" + ) context_vars = { - **value.pop(CONF_DEFAULTS), + **defaults, **(context_vars or {}), } diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 198b002d43c..b0d3656101e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -988,6 +988,64 @@ def test_resolve_include_error_shows_expanded_from_when_substituted( assert "expanded from '${device}.yaml'" in msg +def test_resolve_include_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` in an included file surfaces as cv.Invalid naming that file. + + add_context() raises EsphomeError, so resolve_include's existing handler can + wrap it with the filename and document path. A bare TypeError would escape + that handler and reach the user as an unhandled traceback. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + include = yaml_util.IncludeFile(parent, "_network/ota.yaml", loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + +def test_do_packages_pass_nested_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` two package levels deep still names the offending file. + + Mirrors a real layout: the top-level config includes a network package whose + own `packages:` block includes the file with the empty `defaults:`. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def inner_loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + def outer_loader(_path: Path) -> dict[str, Any]: + return { + "packages": { + "otas": yaml_util.IncludeFile(parent, "_network/ota.yaml", inner_loader) + } + } + + config = OrderedDict( + { + "packages": { + "wifis": yaml_util.IncludeFile(parent, "network.yaml", outer_loader) + } + } + ) + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + def test_resolve_include_error_no_expanded_from_for_literal_filename( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c30b03467f1..c3276823ecc 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -569,6 +569,29 @@ def test_config_context_defaults_explicit_vars_override() -> None: assert "defaults" not in tagged +def test_config_context_defaults_empty_raises() -> None: + """Test that an empty defaults: key raises EsphomeError, not a bare TypeError. + + A ``defaults:`` block with an empty (or comments-only) body parses as None. + Unpacking it used to fail with ``TypeError: 'NoneType' object is not a + mapping``, which escapes the EsphomeError handler in resolve_include and + reaches the user as an unhandled traceback naming no file. + """ + data = {"defaults": None, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' is empty"): + yaml_util.add_context(data, None) + + +@pytest.mark.parametrize("defaults", ["oops", ["a", "b"], 42]) +def test_config_context_defaults_non_mapping_raises(defaults) -> None: + """Test that a non-mapping defaults: value raises EsphomeError naming the type.""" + data = {"defaults": defaults, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' must be a key to value mapping"): + yaml_util.add_context(data, None) + + def test_represent_extend() -> None: """Test that Extend objects are dumped as plain !extend scalars.""" assert yaml_util.dump({"key": Extend("my_id")}) == "key: !extend 'my_id'\n" From 88fdf822cdbc47d127ed686029dc038e17ae0ac3 Mon Sep 17 00:00:00 2001 From: jcianci12 Date: Tue, 13 Oct 2026 11:41:30 +1000 Subject: [PATCH 14/66] [runtime_image] Speed up RGB565 JPEG decoding (#19454) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../runtime_image/image_decoder.cpp | 3 + .../components/runtime_image/image_decoder.h | 4 + .../components/runtime_image/jpeg_decoder.cpp | 90 +++++++++++++++++-- .../components/runtime_image/jpeg_decoder.h | 12 ++- .../components/runtime_image/runtime_image.h | 2 + .../runtime_image/test_decoder_reuse.cpp | 84 +++++++++++++++++ 6 files changed, 188 insertions(+), 7 deletions(-) diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 7056ce35aa8..55e25d76b72 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -29,4 +29,7 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { } } +uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; } + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 2a8b3938886..8e0399ac354 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -127,6 +127,10 @@ class ImageDecoder { } protected: + /// The image's pixel buffer, for decoders that write their output straight into it + uint8_t *image_buffer_() const; + bool image_is_big_endian_() const; + double x_scale_ = 1.0; double y_scale_ = 1.0; RuntimeImage *image_; diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 74ced63a4e8..d4783701c4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_IMAGE_JPEG #include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -15,6 +16,67 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { +bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) { + if (this->x_boundaries_.capacity() < static_cast(width + 1) && !this->x_boundaries_.try_init(width + 1)) { + return false; + } + if (this->y_boundaries_.capacity() < static_cast(height + 1) && !this->y_boundaries_.try_init(height + 1)) { + return false; + } + + this->source_width_ = width; + this->source_height_ = height; + this->x_boundaries_.clear(); + this->y_boundaries_.clear(); + const uint32_t target_width = this->image_->get_buffer_width(); + const uint32_t target_height = this->image_->get_buffer_height(); + // Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source + // pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6; + // the generic draw() path rounds down and keeps the odd ones instead + for (uint32_t x = 0; x <= static_cast(width); x++) { + this->x_boundaries_.push_back(static_cast((x * target_width + width - 1) / width)); + } + for (uint32_t y = 0; y <= static_cast(height); y++) { + this->y_boundaries_.push_back(static_cast((y * target_height + height - 1) / height)); + } + return true; +} + +void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) { + if (pixels == nullptr) { + return; + } + uint8_t *const buffer = this->image_buffer_(); + const uint32_t buffer_width = this->image_->get_buffer_width(); + // JPEGDEC gives little-endian words; a big-endian image stores the high byte first + const uint8_t first = this->image_is_big_endian_() ? 1 : 0; + for (int source_y = 0; source_y < h; source_y++) { + const int absolute_y = y + source_y; + if (absolute_y < 0 || absolute_y >= this->source_height_) { + continue; + } + const uint16_t y1 = this->y_boundaries_[absolute_y]; + const uint16_t y2 = this->y_boundaries_[absolute_y + 1]; + for (int source_x = 0; source_x < w; source_x++) { + const int absolute_x = x + source_x; + if (absolute_x < 0 || absolute_x >= this->source_width_) { + continue; + } + const uint16_t x1 = this->x_boundaries_[absolute_x]; + const uint16_t x2 = this->x_boundaries_[absolute_x + 1]; + const uint8_t *pixel = pixels + (source_y * w + source_x) * 2; + for (uint16_t destination_y = y1; destination_y < y2; destination_y++) { + uint8_t *destination = buffer + (destination_y * buffer_width + x1) * 2; + for (uint16_t destination_x = x1; destination_x < x2; destination_x++) { + destination[0] = pixel[first]; + destination[1] = pixel[1 - first]; + destination += 2; + } + } + } + } +} + /** * @brief Callback method that will be called by the JPEGDEC engine when a chunk * of the image is decoded. @@ -22,7 +84,12 @@ namespace esphome::runtime_image { * @param jpeg The JPEGDRAW object, including the context data. */ static int draw_callback(JPEGDRAW *jpeg) { - ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser; + JpegDecoder *decoder = (JpegDecoder *) jpeg->pUser; + + if (decoder == nullptr) { + ESP_LOGE(TAG, "Decoder pointer is null!"); + return 0; + } // Some very big images take too long to decode, so feed the watchdog on each callback // to avoid crashing if the executing task has a watchdog enabled. @@ -33,6 +100,11 @@ static int draw_callback(JPEGDRAW *jpeg) { #ifdef USE_ESP_IDF } #endif + if (decoder->uses_direct_rgb565()) { + decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight, + reinterpret_cast(jpeg->pPixels)); + return 1; + } size_t position = 0; size_t height = static_cast(jpeg->iHeight); size_t width = static_cast(jpeg->iWidth); @@ -42,10 +114,6 @@ static int draw_callback(JPEGDRAW *jpeg) { auto ba = decode_value(jpeg->pPixels[position++]); Color color(rg[1], rg[0], ba[1], ba[0]); - if (!decoder) { - ESP_LOGE(TAG, "Decoder pointer is null!"); - return 0; - } decoder->draw(jpeg->x + x, jpeg->y + y, 1, 1, color); } } @@ -78,10 +146,20 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp()); this->jpeg_.setUserPointer(this); - this->jpeg_.setPixelType(RGB8888); + this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency(); + if (this->direct_rgb565_) { + // JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build + // returns little-endian words for it), so the byte order is applied in draw_rgb565 instead + this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN); + } else { + this->jpeg_.setPixelType(RGB8888); + } if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { return DECODE_ERROR_OUT_OF_MEMORY; } + if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { + return DECODE_ERROR_OUT_OF_MEMORY; + } if (!this->jpeg_.decode(0, 0, 0)) { auto error = this->jpeg_.getLastError(); ESP_LOGE(TAG, "Error while decoding: %d", error); diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index 67c9b77f4d7..f72f69422d0 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -1,8 +1,8 @@ #pragma once - #include "image_decoder.h" #include "runtime_image.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_RUNTIME_IMAGE_JPEG #include @@ -23,8 +23,18 @@ class JpegDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; + bool uses_direct_rgb565() const { return this->direct_rgb565_; } + void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels); + protected: + bool prepare_rgb565_scaling_(int width, int height); + JPEGDEC jpeg_{}; + FixedVector x_boundaries_{}; + FixedVector y_boundaries_{}; + int source_width_{0}; + int source_height_{0}; + bool direct_rgb565_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 55d5c0ae866..33bad52acf7 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -234,6 +234,8 @@ class RuntimeImage : public image::Image { bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; + + friend class ImageDecoder; }; } // namespace esphome::runtime_image diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp index e62e2a98d92..9f7190980bf 100644 --- a/tests/components/runtime_image/test_decoder_reuse.cpp +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -101,6 +101,9 @@ class TestableRuntimeImage : public RuntimeImage { explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE) : RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {} + TestableRuntimeImage(ImageFormat format, image::ImageType type, bool is_big_endian, int fixed_width, int fixed_height) + : RuntimeImage(format, type, image::TRANSPARENCY_OPAQUE, nullptr, is_big_endian, fixed_width, fixed_height) {} + ImageDecoder *decoder() { return this->decoder_.get(); } }; @@ -373,6 +376,87 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { EXPECT_EQ(img.decoder(), first); EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; } + +// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each +// channel is checked within one step; the byte order is still exact +static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) { + EXPECT_NEAR(actual >> 11, expected >> 11, 1) << order << " pixel " << pixel << " red"; + EXPECT_NEAR((actual >> 5) & 0x3F, (expected >> 5) & 0x3F, 1) << order << " pixel " << pixel << " green"; + EXPECT_NEAR(actual & 0x1F, expected & 0x1F, 1) << order << " pixel " << pixel << " blue"; +} + +TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) { + // Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those + // pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source + // pixel (2, 0), which is word 0x3803 here. + static const uint16_t EXPECTED[4 * 4] = { + 0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F, + 0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17, + }; + TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4); + TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4); + + ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(rgb565_little_endian.get_width(), 4); + ASSERT_EQ(rgb565_little_endian.get_height(), 4); + ASSERT_EQ(rgb565_big_endian.get_width(), 4); + ASSERT_EQ(rgb565_big_endian.get_height(), 4); + + const uint8_t *little = rgb565_little_endian.get_data_start(); + const uint8_t *big = rgb565_big_endian.get_data_start(); + for (size_t i = 0; i < 4 * 4; i++) { + expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i); + expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i); + } +} + +// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way +// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start +static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; } + +// The RGB565 word at (x, y) of a decoded image, in its own byte order +static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) { + const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2; + return p[0] | (p[1] << 8); +} + +// Every scaled direct decode must equal the unscaled one sampled through the boundary table, +// for shrinking, growing and a ratio that is not a whole number +TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) { + TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8); + ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + + for (int size : {4, 6, 16}) { + TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size); + ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(scaled.get_width(), size); + ASSERT_EQ(scaled.get_height(), size); + for (int n = 0; n < 8; n++) { + for (int m = 0; m < 8; m++) { + for (int y = scaled_boundary(m, 8, size); y < scaled_boundary(m + 1, 8, size); y++) { + for (int x = scaled_boundary(n, 8, size); x < scaled_boundary(n + 1, 8, size); x++) { + EXPECT_EQ(rgb565_word(scaled, x, y), rgb565_word(full, n, m)) + << size << "x" << size << " pixel " << x << "," << y; + } + } + } + } + } +} + +// A second decode into the same image reuses the boundary tables and gives the same pixels +TEST(RuntimeImageDecoder, JpegDirectRgb565RepeatsWithReusedTables) { + TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 6, 6); + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels); +} #endif // USE_RUNTIME_IMAGE_JPEG TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) { From a035c17e0482bde73510d321334d03fcc57b15e8 Mon Sep 17 00:00:00 2001 From: t2bottom <99073885+t2bottom@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:42:24 +0200 Subject: [PATCH 15/66] [gt911] Fix the interrupt pin initialisation after reset (#15021) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../gt911/touchscreen/gt911_touchscreen.cpp | 23 +++++++++++-------- .../gt911/touchscreen/gt911_touchscreen.h | 13 ++--------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07dd325a2c7..388b8165fd7 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned return; \ } +// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then +// T3 + T4 pass before INT is handed back to the chip as its interrupt output +static constexpr uint32_t RESET_LOW_MS = 10; +static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1; + void GT911Touchscreen::setup() { if (this->reset_pin_ != nullptr) { - // temporarily set the interrupt pin to output to control address selection this->reset_pin_->setup(); this->reset_pin_->digital_write(false); if (this->interrupt_pin_ != nullptr) { @@ -34,20 +38,19 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); this->interrupt_pin_->digital_write(false); } - delay(2); - this->reset_pin_->digital_write(true); - // wait at least T3+T4 ms as per the datasheet - this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); }); + this->set_timeout(RESET_LOW_MS, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); }); + }); return; } this->setup_internal_(); } void GT911Touchscreen::setup_internal_() { - if (this->interrupt_pin_ != nullptr) { - if (this->interrupt_pin_->is_internal()) - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - } + // Expander pins must leave output mode too, or the chip cannot drive its interrupt line + if (this->interrupt_pin_ != nullptr) + this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); uint8_t data[4]; i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() { ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } else { - // IO expander pin: leave as output for configuration only. + // IO expander pin: no hardware interrupt, the touch state is polled. ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 465df528e5f..13fb6b05dab 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -14,17 +14,8 @@ class GT911ButtonListener { class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: - /// @brief Initialize the GT911 touchscreen. - /// - /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, - /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() - /// to allow the device to stabilize after reset. - /// - /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset - /// to control I2C address selection. - /// - /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() - /// is called to complete the initialization. + /// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the + /// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once. void setup() override; void dump_config() override; bool can_proceed() override { return this->setup_done_; } From 36900e36f91ecd271cb6f1afee820f161aec635c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:09 -1000 Subject: [PATCH 16/66] [esp32_power_management] Build the flash power-down path in a standalone test variant (#20612) --- tests/components/esp32_power_management/common.yaml | 2 +- .../test-flash-power-down.esp32-idf.yaml | 8 ++++++++ .../components/esp32_power_management/test.esp32-ard.yaml | 1 + .../esp32_power_management/test.esp32-h2-idf.yaml | 1 + .../components/esp32_power_management/test.esp32-idf.yaml | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml index 74d93e6577f..78d64c634a3 100644 --- a/tests/components/esp32_power_management/common.yaml +++ b/tests/components/esp32_power_management/common.yaml @@ -5,7 +5,7 @@ power_management: min_frequency: ${esp32_power_management_min_frequency} enable_light_sleep: true idle_time_before_sleep: 3 - power_down_flash: true + power_down_flash: ${esp32_power_management_power_down_flash} power_down_peripherals: ${esp32_power_management_power_down_peripherals} profiling: true trace: true diff --git a/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml new file mode 100644 index 00000000000..364ad5329db --- /dev/null +++ b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml index 2b25b2d23f9..513617a53b4 100644 --- a/tests/components/esp32_power_management/test.esp32-ard.yaml +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml index c571a2be0cd..7bf893aeff6 100644 --- a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 96MHZ esp32_power_management_min_frequency: 32MHZ esp32_power_management_power_down_peripherals: "true" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml index 2b25b2d23f9..513617a53b4 100644 --- a/tests/components/esp32_power_management/test.esp32-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml From fb14db267cbca1fe96a1a5ed4f4e8eac8c8c85f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:52 -1000 Subject: [PATCH 17/66] [const] Move CONF_SERIAL_NUMBER to the shared component constants (#20611) --- esphome/components/const/__init__.py | 1 + esphome/components/hoermann_hcp/text_sensor/__init__.py | 2 +- esphome/components/sun_gtil2/text_sensor.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 78778553aab..49f55ab47f0 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -59,6 +59,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" +CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index da460905c0e..7aedf599249 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP from esphome.types import ConfigType @@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] CONF_DOOR_STATE = "door_state" -CONF_SERIAL_NUMBER = "serial_number" HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index c0a60cacbf3..753401e6aa8 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -1,13 +1,12 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_STATE from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component -CONF_SERIAL_NUMBER = "serial_number" - CONFIG_SCHEMA = cv.All( cv.Schema( { From 5a39097b3a73052d09fe06d8fb5770605961e22e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:46:41 -1000 Subject: [PATCH 18/66] [light] Keep one copy of the color mode lookup table in flash (#20394) --- esphome/components/light/color_mode.h | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index 0750ae250dc..fbce5232ff2 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -2,6 +2,7 @@ #include #include "esphome/core/finite_set_mask.h" +#include "esphome/core/hal.h" namespace esphome::light { @@ -108,8 +109,9 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) { using color_mode_bitmask_t = uint16_t; // Lookup table for ColorMode bit mapping -// This array defines the canonical order of color modes (bit 0-9) -constexpr ColorMode COLOR_MODE_LOOKUP[] = { +// This array defines the canonical order of color modes (bit 0-9). One shared copy in flash; read it at run time +// through color_mode_at(). +inline constexpr ColorMode COLOR_MODE_LOOKUP[] PROGMEM = { ColorMode::UNKNOWN, // bit 0 ColorMode::ON_OFF, // bit 1 ColorMode::BRIGHTNESS, // bit 2 @@ -122,6 +124,12 @@ constexpr ColorMode COLOR_MODE_LOOKUP[] = { ColorMode::RGB_COLD_WARM_WHITE, // bit 9 }; +constexpr ColorMode color_mode_at(unsigned bit) { + if (__builtin_is_constant_evaluated()) + return COLOR_MODE_LOOKUP[bit]; + return static_cast(progmem_read_byte(reinterpret_cast(&COLOR_MODE_LOOKUP[bit]))); +} + /// Bit mapping policy for ColorMode /// Uses lookup table for non-contiguous enum values struct ColorModeBitPolicy { @@ -129,17 +137,15 @@ struct ColorModeBitPolicy { static constexpr int MAX_BITS = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); static constexpr unsigned to_bit(ColorMode mode) { - // Linear search through lookup table - // Compiler optimizes this to efficient code since array is constexpr for (int i = 0; i < MAX_BITS; ++i) { - if (COLOR_MODE_LOOKUP[i] == mode) + if (color_mode_at(i) == mode) return i; } return 0; } static constexpr ColorMode from_bit(unsigned bit) { - return (bit < MAX_BITS) ? COLOR_MODE_LOOKUP[bit] : ColorMode::UNKNOWN; + return (bit < MAX_BITS) ? color_mode_at(bit) : ColorMode::UNKNOWN; } }; @@ -157,7 +163,7 @@ constexpr uint16_t compute_capability_bitmask(ColorCapability capability) { // Check each ColorMode to see if it has this capability constexpr int color_mode_count = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); for (int bit = 0; bit < color_mode_count; ++bit) { - uint8_t mode_val = static_cast(COLOR_MODE_LOOKUP[bit]); + uint8_t mode_val = static_cast(color_mode_at(bit)); if ((mode_val & cap_bit) != 0) { mask |= (1 << bit); } From 2001f60954ea807ddf29b21a41bf0a2105d61f1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:05 -1000 Subject: [PATCH 19/66] [core] Keep string constants in apply conditions in flash on ESP8266 (#20107) --- esphome/automation.py | 27 +++++++++++++++++++++------ esphome/core/string_ref.h | 19 +++++++++++++++++++ tests/unit_tests/test_automation.py | 22 +++++++++++++++++----- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 393a79422bc..4a2c10cb52d 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.progmem_string(value)) +def flash_compare(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266.""" + if CORE.is_esp8266: + return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}" + return str(cg.safe_exp(value)) + + +def _default_std_string( + members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str] +) -> list[tuple[str, Any, Any]]: + """Give ``cg.std_string`` members without a renderer the given default.""" + return [ + (key, t, fn or (renderer if t is cg.std_string else None)) + for key, t, fn in members + ] + + def literal_with_length(config: ConfigType, value: str) -> str: """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. @@ -491,10 +508,7 @@ def apply_action_builder( statements_spec = [ ( c.target, - [ - (key, t, fn or (flash_string if t is cg.std_string else None)) - for key, t, fn in c.members - ], + _default_std_string(c.members, flash_string), ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -563,11 +577,12 @@ def register_apply_condition( ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. - String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=`` + against a ``std::string`` or ``StringRef`` member; other string types are plain literals. Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) - members = call.members + members = _default_std_string(call.members, flash_compare) _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 14275412294..e9e31411e42 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -188,6 +188,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); } inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } + +/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. +struct ProgmemStringRef { + PGM_P str; + size_t len; +}; + +inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } + +inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f2d46280448..fe0b1b02ed1 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1293,13 +1293,25 @@ async def test_apply_condition_compares_config_value( @pytest.mark.asyncio -@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) -async def test_apply_condition_string_constant_is_a_plain_literal( - registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +@pytest.mark.parametrize( + ("platform", "value", "expected"), + [ + ("esp32", "two", '"two"'), + ("esp8266", "two", '::esphome::ProgmemStringRef{PSTR("two"), 3}'), + # The length is in bytes, not characters + ("esp8266", "é", '::esphome::ProgmemStringRef{PSTR("\\303\\251"), 2}'), + ], +) +async def test_apply_condition_string_constant( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + value: str, + expected: str, ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) - await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + await _run_apply_condition(registries, check, {"state": value}, platform=platform) + assert f"return ::{PARENT_OBJ}->state == {expected};" in _apply_definition(mock_cg) @pytest.mark.asyncio From 02287fdf7b0de1d45a908884867fb8d813d44a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:27 -1000 Subject: [PATCH 20/66] [sensor] Share filter_out and throttle_with_priority value tables in flash (#20078) --- esphome/components/sensor/__init__.py | 32 ++++++++---- esphome/components/sensor/filter.cpp | 21 ++++---- esphome/components/sensor/filter.h | 49 ++++++------------ esphome/core/automation.h | 5 +- .../component_tests/sensor/test_value_list.py | 43 ++++++++++++++++ .../sensor/test_value_list.yaml | 51 +++++++++++++++++++ 6 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 tests/component_tests/sensor/test_value_list.py create mode 100644 tests/component_tests/sensor/test_value_list.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8f345e3e668..79c6785be24 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,7 +113,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, @@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, template_) +TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_) + + +async def _value_list_table(values: list[Any]) -> MockObj: + """PROGMEM value table, ended by an empty entry so filters store no count.""" + fns = [await cg.templatable(x, [], cg.float_) for x in values] + # Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share. + return cg.shared_progmem_array( + "sensor_value_list", + TemplatableFloat, + [*fns, TemplatableFloat()], + share=not any(isinstance(v, Lambda) for v in values), + ) + + @FILTER_REGISTRY.register( "filter_out", FilterOutValueFilter, cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]), ) -async def filter_out_filter_to_code(config, filter_id): +async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj: if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], cg.float_) for x in config] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) + return cg.new_Pvariable(filter_id, await _value_list_table(config)) QUANTILE_SCHEMA = cv.All( @@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool: ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA, ) -async def throttle_with_priority_filter_to_code(config, filter_id): +async def throttle_with_priority_filter_to_code( + config: ConfigType, filter_id: ID +) -> MockObj: values = config[CONF_VALUE] if not isinstance(values, list): values = [values] @@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) - template_ = [await cg.templatable(x, [], cg.float_) for x in values] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ - ) + table = await _value_list_table(values) + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index ed48ddebdbc..9b027c4e11f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(mu optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { - int8_t accuracy = parent->get_accuracy_decimals(); +// ValueListFilter +bool ValueListFilter::value_matches_any_(float sensor_value) { + int8_t accuracy = this->parent_->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (size_t i = 0; i < count; i++) { - float fv = values[i].value(); + for (const TemplatableFn *v = this->values_; v->has_value(); v++) { + float fv = v->value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -261,13 +261,12 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { +// ThrottleWithPriorityFilter +optional ThrottleWithPriorityFilter::new_value(float value) { const uint32_t now = App.get_loop_component_start_time(); - if (last_input == 0 || now - last_input >= min_time_between_inputs || - value_list_matches_any(parent, value, values, count)) { - last_input = now; + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || + this->value_matches_any_(value)) { + this->last_input_ = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 9b5ef493081..5ff99881dea 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,36 +330,27 @@ class MultiplyFilter : public Filter { TemplatableFn multiplier_; }; -/// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); +// Value tables are read in place from flash, which ESP8266 only allows for whole words. +static_assert(sizeof(TemplatableFn) == sizeof(void *), "TemplatableFn must stay a single pointer"); -/** Base class for filters that compare sensor values against a fixed list of configured values. - * - * Templated on N (the number of values) so the list is stored inline in a std::array, - * avoiding heap allocation and the overhead of FixedVector. - * - * @tparam N Number of values in the filter list, set by code generation to match - * the exact number of values configured in YAML. - */ -template class ValueListFilter : public Filter { +/// Base class for filters that compare sensor values against a fixed list of configured values. +class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { - init_array_from(this->values_, values); - } + explicit ValueListFilter(const TemplatableFn *values) : values_(values) {} /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value) { - return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); - } + bool value_matches_any_(float sensor_value); - std::array, N> values_{}; + // Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function + // pointers, so aligned 32-bit loads from flash are safe on ESP8266. + const TemplatableFn *values_; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -template class FilterOutValueFilter : public ValueListFilter { +class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} + explicit FilterOutValueFilter(const TemplatableFn *values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { if (this->value_matches_any_(value)) @@ -379,21 +370,13 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; -/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); - /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -template class ThrottleWithPriorityFilter : public ValueListFilter { +class ThrottleWithPriorityFilter : public ValueListFilter { public: - explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} + ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn *prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override { - return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, - this->min_time_between_inputs_); - } + optional new_value(float value) override; protected: uint32_t last_input_{0}; diff --git a/esphome/core/automation.h b/esphome/core/automation.h index bbb7f08fb8e..e7cf1c1143a 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -19,11 +19,12 @@ namespace esphome { /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. template class TemplatableFn { public: - TemplatableFn() = default; + constexpr TemplatableFn() = default; TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage - template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + // constexpr so codegen can place arrays of these in flash. + template constexpr TemplatableFn(F f) requires std::convertible_to : f_(f) {} // Convertible return type (e.g., int -> uint8_t) — casting trampoline. // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside diff --git a/tests/component_tests/sensor/test_value_list.py b/tests/component_tests/sensor/test_value_list.py new file mode 100644 index 00000000000..168fee24275 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.py @@ -0,0 +1,43 @@ +"""Tests for the sensor value list filter codegen.""" + +from collections.abc import Callable +import re + + +def test_value_list_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Identical value lists share one PROGMEM table; the NaN throttle stays array free.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM", main_cpp + ) + # Every table ends with an empty entry so the filters store no count. + assert main_cpp.count("TemplatableFn()}") == len(tables) + assert ( + len(tables) == 5 + ) # single, shared list, lambda, two anchored stateful lambdas + single, shared, lam = tables[:3] + assert f"sensor::FilterOutValueFilter({single});" in main_cpp + assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2 + assert f"sensor::FilterOutValueFilter({lam});" in main_cpp + assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp + assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp + assert "return 42.0;" in main_cpp + + +def test_value_list_lambda_tables_are_not_shared( + generate_main: Callable[[str], str], +) -> None: + """An anchored lambda may keep static state, so each filter keeps its own table.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM = (.*?)TemplatableFn\(\)\}", + main_cpp, + re.DOTALL, + ) + stateful = [name for name, body in tables if "static float n" in body] + assert len(stateful) == 2 + assert stateful[0] != stateful[1] diff --git a/tests/component_tests/sensor/test_value_list.yaml b/tests/component_tests/sensor/test_value_list.yaml new file mode 100644 index 00000000000..60a044f483c --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.yaml @@ -0,0 +1,51 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: fo_single + lambda: return 1.0; + filters: + - filter_out: 85.0 + - platform: template + id: fo_list_a + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_list_b + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_lambda + lambda: return 1.0; + filters: + - filter_out: !lambda return 42.0; + - platform: template + id: twp + lambda: return 1.0; + filters: + - throttle_with_priority: + timeout: 1s + value: [0.0, 85.0] + - platform: template + id: twp_nan + lambda: return 1.0; + filters: + - throttle_with_priority: 1s + - platform: template + id: fo_stateful_a + lambda: return 1.0; + filters: + - filter_out: &stateful !lambda |- + static float n = 0; + return ++n; + - platform: template + id: fo_stateful_b + lambda: return 1.0; + filters: + - filter_out: *stateful From d6e5b9b03bd037954e6733c3505d385bc375f7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 03:50:12 +0200 Subject: [PATCH 21/66] [bq25186] Add the status sensors, buttons, PG/GPO switch and watchdog option (part 2) (#15736) Co-authored-by: J. Nick Koston --- esphome/components/bq25186/__init__.py | 87 ++++++++++++-- esphome/components/bq25186/binary_sensor.py | 73 ++++++++++++ esphome/components/bq25186/bq25186.cpp | 106 +++++++++++++++++- esphome/components/bq25186/bq25186.h | 57 +++++++++- .../bq25186/bq25186_binary_sensor.h | 31 +++++ esphome/components/bq25186/bq25186_button.h | 25 +++++ esphome/components/bq25186/bq25186_switch.h | 21 ++++ .../bq25186/bq25186_text_sensor.cpp | 26 +++++ .../components/bq25186/bq25186_text_sensor.h | 26 +++++ esphome/components/bq25186/button.py | 52 +++++++++ esphome/components/bq25186/switch.py | 27 +++++ esphome/components/bq25186/text_sensor.py | 38 +++++++ esphome/core/defines.h | 3 + .../bq25186/test_pack_registers.py | 50 +++++++++ tests/components/bq25186/common.yaml | 74 ++++++++++++ 15 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 esphome/components/bq25186/binary_sensor.py create mode 100644 esphome/components/bq25186/bq25186_binary_sensor.h create mode 100644 esphome/components/bq25186/bq25186_button.h create mode 100644 esphome/components/bq25186/bq25186_switch.h create mode 100644 esphome/components/bq25186/bq25186_text_sensor.cpp create mode 100644 esphome/components/bq25186/bq25186_text_sensor.h create mode 100644 esphome/components/bq25186/button.py create mode 100644 esphome/components/bq25186/switch.py create mode 100644 esphome/components/bq25186/text_sensor.py diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index e57860786f5..03a100d9297 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -5,8 +5,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE, ID, Lambda +from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG +from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@Rapsssito"] @@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"] DOMAIN = "bq25186" MULTI_CONF = True +CONF_BQ25186_ID = "bq25186_id" CONF_CONFIGURE_ON_BOOT = "configure_on_boot" CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" CONF_FAST_CHARGE_CURRENT = "fast_charge_current" @@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer" CONF_ENABLE_PUSH_BUTTON = "enable_push_button" CONF_SYSTEM_REGULATION = "system_regulation" CONF_SYS_MODE = "sys_mode" +CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable" CONF_DISABLE_VDPPM = "disable_vdppm" CONF_TS_HOT = "ts_hot" CONF_TS_COLD = "ts_cold" @@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" bq25186_ns = cg.esphome_ns.namespace("bq25186") -BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) +BQ25186Component = bq25186_ns.class_( + "BQ25186Component", cg.PollingComponent, i2c.I2CDevice +) + +BQ25186_CHILD_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} +) +_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") +_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") + + +def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_binary_sensor_slot(str(hub)) + return hub.register_binary_sensor(var) + + +def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_text_sensor_slot(str(hub)) + return hub.register_text_sensor(var) + REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 @@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A REG_TS_CONTROL = 0x0B REG_MASK_ID = 0x0C REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 -WATCHDOG_DISABLED = 0x03 TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} @@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = { VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +# The I2C watchdog restores the power-on registers when the bus stays quiet for its period +WATCHDOG_OPTIONS = { + "160s_software_reset": 0, + "160s_hardware_reset": 1, + "40s_hardware_reset": 2, + "disabled": 3, +} +WATCHDOG_PERIOD_S = { + "160s_software_reset": 160, + "160s_hardware_reset": 160, + "40s_hardware_reset": 40, +} +# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects +WATCHDOG_15S_PERIOD_S = 15 +# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied +POWER_ON_WATCHDOG_PERIOD_S = 160 LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} INPUT_CURRENT_LIMIT_OPTIONS = { @@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float: # (config key, register, bit shift, validator or enum options, default). -# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are -# driven at runtime, not from configuration. +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO +# switch drives them. FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( ( CONF_BATTERY_REGULATION_VOLTAGE, @@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"), (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), @@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False), (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), @@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = { def pack_registers(config: ConfigType) -> list[int]: """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" regs = [0] * REGISTER_COUNT - # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running - # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. - regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED for key, register, shift, validator, _ in FIELDS: value = config[key] if isinstance(validator, dict): @@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema: BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) -CONFIG_SCHEMA = ( + +def _check_watchdog(config: ConfigType) -> ConfigType: + """Only the status poll talks to the chip, so it has to come round before any watchdog does.""" + periods = [] + if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None: + periods.append(period) + if config[CONF_WATCHDOG_15S_ENABLE]: + periods.append(WATCHDOG_15S_PERIOD_S) + if not config.get(CONF_CONFIGURE_ON_BOOT, True): + periods.append(POWER_ON_WATCHDOG_PERIOD_S) + if not periods: + return config + if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))): + raise cv.Invalid( + f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the " + f"watchdog resets the charger between status polls", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BQ25186Component), @@ -267,7 +323,9 @@ CONFIG_SCHEMA = ( } ) .extend(BQ25186_CONFIG_SCHEMA) - .extend(i2c.i2c_device_schema(0x6A)) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6A)), + _check_watchdog, ) CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( @@ -305,7 +363,12 @@ async def bq25186_configure_to_code( # Options the action leaves out fall back to the component's configuration path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] parent_config = CORE.config.get_config_for_path(path) - regs = pack_registers({**parent_config, **config}) + merged = {**parent_config, **config} + try: + _check_watchdog(merged) + except cv.Invalid as err: + raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err + regs = pack_registers(merged) text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py new file mode 100644 index 00000000000..fb741950e29 --- /dev/null +++ b/esphome/components/bq25186/binary_sensor.py @@ -0,0 +1,73 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_RUNNING, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186BinarySensor = bq25186_ns.class_( + "BQ25186BinarySensor", binary_sensor.BinarySensor +) + +REG_STAT0 = 0 +REG_STAT1 = 1 +REG_FLAG0 = 2 +# Bit set for each field value that reads as on: a single bit is on when it is 1 +_BIT_SET = 0b10 + +# (key, status register, bit shift, field mask, on-values bitmap, device class) +_FIELDS = ( + ("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING), + ("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING), + ("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED), + ("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG), + ("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED), + ("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED), + ("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED), + ("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED), + ("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED), + ("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED), + ("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): binary_sensor.binary_sensor_schema( + BQ25186BinarySensor, device_class=device_class + ) + for key, *_, device_class in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + for key, reg, shift, mask, match, _ in _FIELDS: + await sensors( + key, partial(register_binary_sensor, hub), reg, shift, mask, match + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index e660ec63d14..edea50d1115 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,4 +1,10 @@ #include "bq25186.h" +#ifdef USE_BINARY_SENSOR +#include "bq25186_binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "bq25186_text_sensor.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -7,10 +13,10 @@ namespace esphome::bq25186 { ESPHOME_LOG_TAG(TAG, "bq25186"); -// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, -// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset +// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { - 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, }; bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { @@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value return this->write_register_(reg, reg_value); } +uint8_t BQ25186Component::expected_register_(size_t index) const { + uint8_t value = this->regs_[index]; +#ifdef USE_BQ25186_PG_GPO + if (index == 0 && this->pg_gpo_switch_ != nullptr) + value |= BQ25186_VBAT_CTRL_PG_MODE; +#endif + return value; +} + +bool BQ25186Component::config_matches_(const BQ25186StatusRegisters ®s) const { + for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) { + const size_t index = reg - BQ25186_REG_VBAT_CTRL; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]); + if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0) + return false; + } + return true; +} + bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { // Setup rejects an unknown chip; the action must not program it before that check or after it failed if (!this->is_ready() || this->is_failed()) { ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); return false; } + this->regs_ = regs; + this->configured_ = true; for (size_t i = 0; i < regs.size(); i++) { const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); - const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + const uint8_t value = this->expected_register_(i); + const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value); if (!ok) { this->status_set_warning(LOG_STR("Configuration partly applied")); return false; @@ -68,12 +96,80 @@ void BQ25186Component::setup() { // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry if (this->configure_on_boot_) - this->apply_config(this->setup_regs_); + this->apply_config(this->regs_); +#ifdef USE_BQ25186_PG_GPO + if (this->pg_gpo_switch_ != nullptr) { + // The poll below puts the pin in output mode; without a restore mode the switch takes its level from it + auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode(); + if (initial.has_value()) + this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin + } +#endif + // The poller first runs after a random share of the interval; the entities get a state now + this->update(); +} + +#ifdef USE_BQ25186_PG_GPO +// The pin mode and level come from the switch, so every poll puts back whatever the chip lost +void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) { + if (this->pg_gpo_switch_ == nullptr) + return; + if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 && + !this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) { + this->status_set_warning(LOG_STR("PG/GPO pin not in output mode")); + pending = true; + } + const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; + if (!this->pg_gpo_switch_->has_state()) { + this->pg_gpo_switch_->publish_state(level); + } else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) { + this->status_set_warning(LOG_STR("PG/GPO level write failed")); + pending = true; + } +} +#endif + +// Every poll also serves the I2C watchdog, which wants to see traffic within its period +void BQ25186Component::update() { + BQ25186StatusRegisters status; + if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) { + this->status_set_warning(LOG_STR("Status read failed")); + return; + } + // Whatever failed is tried again here, so the warning only stays while something is still wrong + bool pending = false; + // A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register + // behind; either way the configuration is written again + if (this->configured_ && !this->config_matches_(status)) { + ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again"); + if (this->apply_config(this->regs_)) { + status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured + } else { + pending = true; + } + } +#ifdef USE_BQ25186_PG_GPO + this->check_pg_gpo_(status, pending); +#endif + if (!pending) + this->status_clear_warning(); +#ifdef BQ25186_BINARY_SENSOR_COUNT + for (auto *sensor : this->binary_sensors_) + sensor->on_status(status); +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + for (auto *sensor : this->text_sensors_) + sensor->on_status(status); +#endif } void BQ25186Component::dump_config() { ESP_LOGCONFIG(TAG, "BQ25186:"); LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +#ifdef USE_BQ25186_PG_GPO + LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_); +#endif } } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index a6920871475..0a9a45c28b4 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BQ25186_PG_GPO +#include "esphome/components/switch/switch.h" +#endif #include namespace esphome::bq25186 { +static constexpr uint8_t BQ25186_REG_STAT0 = 0x00; +static constexpr uint8_t BQ25186_REG_STAT1 = 0x01; +static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02; static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; @@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; +// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration +// registers, which show whether the chip was reset or a write failed +static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1; + +// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level +static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80; +static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; // Register values for VBAT_CTRL..MASK_ID, packed by code generation using BQ25186ConfigRegisters = std::array; +using BQ25186StatusRegisters = std::array; -class BQ25186Component : public Component, public i2c::I2CDevice { +class BQ25186BinarySensor; +class BQ25186TextSensor; + +class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: void setup() override; + void update() override; void dump_config() override; void set_setup_registers(const BQ25186ConfigRegisters ®s) { - this->setup_regs_ = regs; + this->regs_ = regs; this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); +#ifdef BQ25186_BINARY_SENSOR_COUNT + void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#endif +#ifdef USE_BQ25186_PG_GPO + /// The /PG/GPO pin is put in general purpose output mode when a switch drives it + void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; } + bool write_pg_gpo_level(bool level) { + return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL, + level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0); + } +#endif + /// Write the masked bits of SHIP_RST + bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); } protected: bool write_register_(uint8_t reg, uint8_t value); + uint8_t expected_register_(size_t index) const; + bool config_matches_(const BQ25186StatusRegisters ®s) const; +#ifdef USE_BQ25186_PG_GPO + void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); +#endif bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); - BQ25186ConfigRegisters setup_regs_{}; +#ifdef BQ25186_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + StaticVector text_sensors_; +#endif +#ifdef USE_BQ25186_PG_GPO + switch_::Switch *pg_gpo_switch_{nullptr}; +#endif + BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; + bool configured_{false}; // regs_ went to the chip at least once }; } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h new file mode 100644 index 00000000000..265f34a4459 --- /dev/null +++ b/esphome/components/bq25186/bq25186_binary_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR + +#include "bq25186.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::bq25186 { + +/// One field of a status register; `match` has a bit set for each field value that reads as on +class BQ25186BinarySensor final : public binary_sensor::BinarySensor { + public: + BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) + : reg_(reg), shift_(shift), mask_(mask), match_(match) {} + + void on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; + this->publish_state((this->match_ >> value) & 1); + } + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t mask_; + const uint8_t match_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_button.h b/esphome/components/bq25186/bq25186_button.h new file mode 100644 index 00000000000..e088166ac6c --- /dev/null +++ b/esphome/components/bq25186/bq25186_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BUTTON + +#include "bq25186.h" +#include "esphome/components/button/button.h" + +namespace esphome::bq25186 { + +/// Writes one SHIP_RST field when pressed +class BQ25186Button final : public button::Button, public Parented { + public: + BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {} + + protected: + void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); } + + const uint8_t mask_; + const uint8_t value_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BUTTON diff --git a/esphome/components/bq25186/bq25186_switch.h b/esphome/components/bq25186/bq25186_switch.h new file mode 100644 index 00000000000..9abe6fec510 --- /dev/null +++ b/esphome/components/bq25186/bq25186_switch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BQ25186_PG_GPO + +#include "bq25186.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::bq25186 { + +class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented { + protected: + void write_state(bool state) override { + if (this->parent_->write_pg_gpo_level(state)) + this->publish_state(state); + } +}; + +} // namespace esphome::bq25186 + +#endif // USE_BQ25186_PG_GPO diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp new file mode 100644 index 00000000000..660313e6413 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.cpp @@ -0,0 +1,26 @@ +#include "bq25186_text_sensor.h" +#ifdef USE_TEXT_SENSOR + +#include "esphome/core/progmem.h" + +namespace esphome::bq25186 { + +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", + "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); + +void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; + if (value == this->last_) + return; + this->last_ = value; + const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX); + char text[32]; + ESPHOME_strncpy_P(text, reinterpret_cast(name), sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h new file mode 100644 index 00000000000..33c8f806fa9 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_TEXT_SENSOR + +#include "bq25186.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::bq25186 { + +/// A two-bit status field; `first` is the string table index of the field's value 0 +class BQ25186TextSensor final : public text_sensor::TextSensor { + public: + BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} + void on_status(const BQ25186StatusRegisters &status); + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t first_; + uint8_t last_{0xFF}; // the field value last published +}; + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/button.py b/esphome/components/bq25186/button.py new file mode 100644 index 00000000000..25527ff9a27 --- /dev/null +++ b/esphome/components/bq25186/button.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ICON_POWER, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button) + +# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes +SOFTWARE_RESET_MASK = 0x80 +MODE_MASK = 0x60 + +# (key, SHIP_RST mask, value, device class, icon) +_BUTTONS = ( + ("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART), + ("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER), + ("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER), + ("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): button.button_schema( + BQ25186Button, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + device_class=device_class, + ) + for key, _, _, device_class, icon in _BUTTONS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)), +) + + +async def to_code(config: ConfigType) -> None: + for key, mask, value, _, _ in _BUTTONS: + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, mask, value) + await cg.register_parented(var, config[CONF_BQ25186_ID]) diff --git a/esphome/components/bq25186/switch.py b/esphome/components/bq25186/switch.py new file mode 100644 index 00000000000..e5eb2d98a2e --- /dev/null +++ b/esphome/components/bq25186/switch.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +CONF_PG_GPO = "pg_gpo" + +BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch) + +CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend( + { + cv.Required(CONF_PG_GPO): switch.switch_schema( + BQ25186PgGpoSwitch, default_restore_mode="DISABLED" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_BQ25186_PG_GPO") + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_PG_GPO, hub.set_pg_gpo_switch) diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py new file mode 100644 index 00000000000..b27d2fbcfae --- /dev/null +++ b/esphome/components/bq25186/text_sensor.py @@ -0,0 +1,38 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) + +REG_STAT0 = 0 +REG_STAT1 = 1 + +# (key, status register, bit shift, index of the field's value 0 in the C++ string table) +_FIELDS = ( + ("charge_status", REG_STAT0, 5, 0), + ("ts_status", REG_STAT1, 3, 4), +) + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) + for key, *_ in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = text_sensor.sub_text_sensors(config) + for key, reg, shift, first in _FIELDS: + await sensors(key, partial(register_text_sensor, hub), reg, shift, first) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c645428c7c1..072abe83742 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -49,6 +49,9 @@ #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 +#define USE_BQ25186_PG_GPO +#define BQ25186_BINARY_SENSOR_COUNT 1 +#define BQ25186_TEXT_SENSOR_COUNT 1 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py index 2ebf6087e7b..940bf5822fa 100644 --- a/tests/component_tests/bq25186/test_pack_registers.py +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -10,12 +10,16 @@ from esphome.components.bq25186 import ( CONF_FAST_CHARGE_CURRENT, CONF_MASK_TS_INTERRUPT, CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + CONF_WATCHDOG_15S_ENABLE, + CONFIG_SCHEMA, REG_ICHG_CTRL, REG_MASK_ID, REG_SHIP_RST, REG_VBAT_CTRL, + _check_watchdog, pack_registers, ) +from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG from esphome.types import ConfigType # Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the @@ -85,3 +89,49 @@ def test_masked_registers_only_carry_configurable_bits() -> None: def test_values_between_steps_are_rejected(key: str, value: str) -> None: with pytest.raises(cv.Invalid): BQ25186_CONFIG_SCHEMA({key: value}) + + +def _watchdog_config(interval: str, options: ConfigType) -> ConfigType: + config = BQ25186_CONFIG_SCHEMA(options) + config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval) + return config + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "160s_software_reset"}), + ("60s", {CONF_WATCHDOG: "160s_hardware_reset"}), + ("30s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG: "disabled"}), + ("10s", {CONF_WATCHDOG_15S_ENABLE: True}), + ], +) +def test_update_interval_within_watchdog_passes( + interval: str, options: ConfigType +) -> None: + _check_watchdog(_watchdog_config(interval, options)) + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("40s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG_15S_ENABLE: True}), + ("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}), + ("never", {CONF_WATCHDOG: "160s_software_reset"}), + ], +) +def test_update_interval_past_watchdog_is_rejected( + interval: str, options: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL): + _check_watchdog(_watchdog_config(interval, options)) + + +def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None: + config = {"configure_on_boot": False, "update_interval": "160s"} + with pytest.raises(cv.Invalid, match="shorter than 160 s"): + CONFIG_SCHEMA(config) + CONFIG_SCHEMA({**config, "update_interval": "60s"}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 9f4b117cd3d..295a68d262b 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -23,3 +23,77 @@ bq25186: address: 0x6B configure_on_boot: true fast_charge_current: 20mA + watchdog: 160s_software_reset + update_interval: 30s + +binary_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + ts: + name: TS Open + charging: + name: Charging + charge_done: + name: Charge Done + ilim: + name: ILIM Active + vdppm: + name: VDPPM Active + vindpm: + name: VINDPM Active + thermreg: + name: Thermal Regulation Active + power_good: + name: VIN Power Good + vin_ovp: + name: VIN OVP Active + battery_uvlo: + name: Battery UVLO Active + safety_timer_flag: + name: Safety Timer Flag + wake1_flag: + name: Wake 1 Flag + wake2_flag: + name: Wake 2 Flag + ts_flag: + name: TS Flag + ilim_flag: + name: ILIM Flag + vdppm_flag: + name: VDPPM Flag + vindpm_flag: + name: VINDPM Flag + thermreg_flag: + name: Thermal Regulation Flag + vin_ovp_flag: + name: VIN OVP Flag + battery_uvlo_flag: + name: Battery UVLO Flag + battery_ocp_flag: + name: Battery OCP Flag + +text_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + charge_status: + name: Charge Status + ts_status: + name: TS Status + +button: + - platform: bq25186 + bq25186_id: bq25186_charger + software_reset: + name: Software Reset + shutdown: + name: Shutdown + ship_mode: + name: Ship Mode + hardware_reset: + name: Hardware Reset + +switch: + - platform: bq25186 + bq25186_id: bq25186_boot + pg_gpo: + name: PG GPO From 98d3d23ce14d802c8429c14067da77b8b467b474 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:59:25 -1000 Subject: [PATCH 22/66] [ecocomfort2] Test the component in isolation (#20613) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8bbb9ed7f9b..f7680f105ca 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", + "ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", From 753c10e5eace236ad573f0c1b668277a77ecae61 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:06:45 +0200 Subject: [PATCH 23/66] [ld2410s] Add the distance sensor, firmware version and common parameters (Part 2) (#8486) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston --- esphome/components/ld2410s/__init__.py | 2 + esphome/components/ld2410s/ld2410s.cpp | 309 +++++++++++++++--- esphome/components/ld2410s/ld2410s.h | 84 ++++- esphome/components/ld2410s/ld2410s_number.h | 32 ++ esphome/components/ld2410s/ld2410s_select.h | 21 ++ esphome/components/ld2410s/number.py | 108 ++++++ esphome/components/ld2410s/select.py | 31 ++ esphome/components/ld2410s/sensor.py | 32 ++ esphome/components/ld2410s/text_sensor.py | 23 ++ esphome/core/defines.h | 2 + tests/components/ld2410s/common-bare.yaml | 28 ++ tests/components/ld2410s/common.yaml | 28 ++ .../ld2410s/test-bare.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 62 ++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 19 ++ tests/integration/test_uart_mock_ld2410s.py | 81 ++++- 16 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 esphome/components/ld2410s/ld2410s_number.h create mode 100644 esphome/components/ld2410s/ld2410s_select.h create mode 100644 esphome/components/ld2410s/number.py create mode 100644 esphome/components/ld2410s/select.py create mode 100644 esphome/components/ld2410s/sensor.py create mode 100644 esphome/components/ld2410s/text_sensor.py create mode 100644 tests/components/ld2410s/common-bare.yaml create mode 100644 tests/components/ld2410s/test-bare.esp8266-ard.yaml diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index 1c39c7eab32..ecc90455d1c 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType +AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@NovakIrs"] DEPENDENCIES = ["uart"] DOMAIN = "ld2410s" @@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id" ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) +Parameter = ld2410s_ns.enum("Parameter", is_class=True) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp index 6462236ac93..3f71c77a62b 100644 --- a/esphome/components/ld2410s/ld2410s.cpp +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -1,4 +1,5 @@ #include "ld2410s.h" +#include "ld2410s_number.h" #include #include #include "esphome/core/application.h" @@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t VERSION_READ_CMD = 0x0000; +static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070; +static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071; static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; // Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; -static constexpr uint8_t CMD_FRAME_MAX_SIZE = - LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); +// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys +static constexpr uint8_t PARAMETER_KEY_SIZE = 2; +static constexpr uint8_t PARAMETER_VALUE_SIZE = 4; +static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE); +static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER); +// Keys of the parameters, in Parameter order +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B}; +static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter"); +#endif +// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words +static constexpr uint8_t VERSION_TYPE_SIZE = 4; +static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2; -// Everything the module needs at start: enter configuration mode, pick the output mode, leave it -static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, - CONFIG_MODE_END_CMD}; +// Everything the module needs at start: configuration mode on, read the version and parameters when +// an entity shows them, pick the output mode, configuration mode off +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = { + CONFIG_MODE_START_CMD, +#ifdef USE_LD2410S_VERSION + VERSION_READ_CMD, +#endif +#ifdef USE_LD2410S_PARAMETERS + PARAMETERS_READ_CMD, +#endif + OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD, +}; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD}; +#endif static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); -static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } -static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]); +#endif +static constexpr uint8_t STEP_MAX_TIMEOUTS = 2; static constexpr uint32_t ACK_TIMEOUT_MS = 300; static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering @@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() { #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); #endif +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_); +#endif +#ifdef USE_LD2410S_VERSION + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + for (auto *number : this->numbers_) { + LOG_NUMBER(" ", "Setting", number); + } +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + LOG_SELECT(" ", "Response Speed", this->response_speed_select_); +#endif } void LD2410SComponent::loop() { @@ -67,35 +110,80 @@ void LD2410SComponent::loop() { avail -= len; } const uint32_t now = App.get_loop_component_start_time(); - this->run_init_sequence_(now); - if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + if (this->sequence_ == nullptr) { + if (!this->init_done_) { + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now); +#ifdef USE_LD2410S_PARAMETERS + } else if (this->parameters_dirty_) { + this->parameters_dirty_ = false; + this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_); +#endif + } + } + this->run_sequence_(now); + if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, this->dropped_frames_); this->dropped_frames_ = 0; - this->status_set_warning(); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now; + this->restart_init_(now); } } -void LD2410SComponent::run_init_sequence_(uint32_t now) { - if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { +#ifdef USE_LD2410S_PARAMETERS +bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) { + // All six are written together, so the other five must be the module's own values + if (!this->init_done_) { + ESP_LOGW(TAG, "Parameters not read yet, change ignored"); + return false; + } + if (!this->parameters_known_) { + ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled"); + return false; + } + this->parameters_[static_cast(parameter)] = value; + this->parameters_dirty_ = true; // written by loop() once no sequence is running + return true; +} +#endif + +void LD2410SComponent::restart_init_(uint32_t at) { + this->init_done_ = false; + this->status_set_warning(); // cleared again when the new init sequence is acknowledged +#ifdef USE_LD2410S_PARAMETERS + // The module's own values count again once it is set up anew + if (this->parameters_dirty_) { + ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back"); + } + this->parameters_dirty_ = false; + this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it +#endif + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at); +} + +void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) { + this->sequence_ = sequence; + this->sequence_len_ = length; + this->step_ = 0; + this->step_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; +} + +uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); } + +void LD2410SComponent::run_sequence_(uint32_t now) { + if (this->sequence_ == nullptr || static_cast(now - this->next_send_at_) < 0) { return; } - const uint16_t command = init_command(this->init_step_); + const uint16_t command = this->current_command_(); if (this->awaiting_ack_) { - if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) { ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, REINIT_PAUSE_MS / 1000); // Leave configuration mode whatever state the module is in, then begin again this->send_command_(CONFIG_MODE_END_CMD); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now + REINIT_PAUSE_MS; + this->restart_init_(now + REINIT_PAUSE_MS); return; } ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); @@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) { frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; frame[pos++] = CONFIG_MODE_START_VALUE >> 8; break; +#ifdef USE_LD2410S_PARAMETERS + case PARAMETERS_READ_CMD: + case PARAMETERS_WRITE_CMD: + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]); + frame[pos++] = key & 0xFF; + frame[pos++] = key >> 8; + if (command == PARAMETERS_WRITE_CMD) { + const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire + for (uint8_t shift = 0; shift < 32; shift += 8) { + frame[pos++] = (value >> shift) & 0xFF; + } + } + } + break; +#endif default: break; } @@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() { } bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { - uint8_t state; - if (this->frame_type_ == FrameType::SHORT_DATA) { - // [state][distance low][distance high] - state = payload[0]; - } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { - // [type][state][distance low][distance high]...; the module only sends these before the init - // sequence switches it to minimal output - state = payload[1]; - } else { - if (len >= 1) { - ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + // Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the + // module only sends them before the init sequence switches it to minimal output + const uint8_t *data = payload; + if (this->frame_type_ == FrameType::STD_DATA) { + if (len < 4 || payload[0] != DATA_TYPE_STANDARD) { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; } - return true; + data = payload + 1; } + const uint8_t state = data[0]; + const uint16_t distance = encode_uint16(data[2], data[1]); // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to // carry the header and footer bytes if (state > 3) { ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); return false; } - this->publish_target_(state > 1); + this->publish_target_(state > 1, distance); return true; } @@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) } const uint16_t command_word = encode_uint16(payload[1], payload[0]); const uint16_t ack = encode_uint16(payload[3], payload[2]); - if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + if (!this->awaiting_ack_) { ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } - if (ack != 0) { - // Leave the command pending so the timeout resends it - ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + const uint16_t command = this->current_command_(); + if (command_word != (command | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } +#ifdef USE_LD2410S_PARAMETERS + if (ack != 0 && command == PARAMETERS_WRITE_CMD) { + // Resending the same values would be rejected again; leave configuration mode and read the + // module's own values back, which republishes them + ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack); + this->send_command_(CONFIG_MODE_END_CMD); + this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS); + return; + } +#endif + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack); + return; + } + // A short answer leaves the read pending the same way, so init cannot finish without the values +#ifdef USE_LD2410S_PARAMETERS + if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4)) + return; +#endif +#ifdef USE_LD2410S_VERSION + if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4)) + return; +#endif this->awaiting_ack_ = false; - this->init_timeouts_ = 0; - this->init_step_++; - this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; - if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + this->step_timeouts_ = 0; + const uint32_t now = App.get_loop_component_start_time(); + this->next_send_at_ = now + COMMAND_GAP_MS; + if (++this->step_ < this->sequence_len_) { + return; + } + const bool was_init = this->sequence_ == INIT_SEQUENCE; + this->sequence_ = nullptr; + if (was_init) { ESP_LOGD(TAG, "Setup done"); - this->last_frame_at_ = App.get_loop_component_start_time(); + this->init_done_ = true; + this->last_frame_at_ = now; this->status_clear_warning(); } } -void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_LD2410S_PARAMETERS +// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short +bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) { + if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) { + ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len); + return false; + } + // Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or + // published and writes stay refused, so a cut-down value is never shown or sent back + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE]; + if ((v[1] | v[2] | v[3]) != 0) { + ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i, + encode_uint32(v[3], v[2], v[1], v[0])); + this->parameters_known_ = false; +#ifdef USE_NUMBER + // A value a number showed before the module answered is not confirmed + for (auto *number : this->numbers_) { + if (number != nullptr) { + number->publish_state(NAN); + } + } +#endif + return true; + } + } + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE]; + } + this->parameters_known_ = true; + this->publish_parameters_(); + ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u", + this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4], + this->parameters_[5]); + return true; +} +#endif + +#ifdef USE_LD2410S_VERSION +// Returns false when the answer is cut short +bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) { + if (len < VERSION_SIZE) { + ESP_LOGW(TAG, "Version answer too short (%u bytes)", len); + return false; + } + const uint8_t *v = &values[VERSION_TYPE_SIZE]; + char version[18]; + snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]), + encode_uint16(v[5], v[4])); + ESP_LOGD(TAG, "Firmware %s", version); + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version); + } + return true; +} +#endif + +void LD2410SComponent::publish_target_(bool target, uint16_t distance) { #ifdef USE_BINARY_SENSOR if (this->target_binary_sensor_ != nullptr) { this->target_binary_sensor_->publish_state(target); } #endif +#ifdef USE_SENSOR + // The module keeps reporting the last distance after the target left + SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0); +#endif } +#ifdef USE_LD2410S_PARAMETERS +void LD2410SComponent::publish_parameters_() { +#ifdef USE_NUMBER + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + if (this->numbers_[i] != nullptr) { + this->numbers_[i]->publish_raw(this->parameters_[i]); + } + } +#endif +#ifdef USE_SELECT + if (this->response_speed_select_ != nullptr) { + const uint8_t speed = this->parameters_[static_cast(Parameter::PARAMETER_RESPONSE_SPEED)]; + for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) { + if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) { + this->response_speed_select_->publish_state(i); + return; + } + } + ESP_LOGV(TAG, "Unknown response speed %u", speed); + } +#endif +} +#endif + } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h index 4af809836ce..9a0a2569742 100644 --- a/esphome/components/ld2410s/ld2410s.h +++ b/esphome/components/ld2410s/ld2410s.h @@ -2,10 +2,21 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ld24xx/ld24xx.h" +#endif +#ifdef USE_LD2410S_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) +#include "esphome/components/select/select.h" +#endif namespace esphome::ld2410s { @@ -13,16 +24,55 @@ namespace esphome::ld2410s { static constexpr size_t RX_BUFFER_SIZE = 96; static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); +/// The module's common parameters, in the order the parameter read answers them +enum class Parameter : uint8_t { + PARAMETER_MAX_DISTANCE, + PARAMETER_MIN_DISTANCE, + PARAMETER_NO_DELAY, + PARAMETER_STATUS_FREQUENCY, + PARAMETER_DISTANCE_FREQUENCY, + PARAMETER_RESPONSE_SPEED, + PARAMETER_COUNT, +}; +static constexpr uint8_t PARAMETER_COUNT = static_cast(Parameter::PARAMETER_COUNT); +// Response speed values, in the order of the select's options (Normal, Fast) +static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10}; +static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS); + +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) +class LD2410SNumber; +#endif + class LD2410SComponent final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(target) #endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(distance, uint16_t) +#endif +#ifdef USE_LD2410S_VERSION + SUB_TEXT_SENSOR(version) +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + SUB_SELECT(response_speed) +#endif public: void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence void loop() override; void dump_config() override; + /// Store a parameter in the module's units; loop() then writes all of them to the module. + /// Returns false, and changes nothing, until the module's own values have been read. +#ifdef USE_LD2410S_PARAMETERS + bool set_parameter(Parameter parameter, uint8_t value); +#ifdef USE_NUMBER + void set_number(Parameter parameter, LD2410SNumber *number) { + this->numbers_[static_cast(parameter)] = number; + } +#endif +#endif + protected: enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; @@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. bool handle_data_frame_(const uint8_t *payload, uint16_t len); void handle_command_ack_(const uint8_t *payload, uint16_t len); +#ifdef USE_LD2410S_PARAMETERS + bool handle_parameters_(const uint8_t *values, uint16_t len); +#endif +#ifdef USE_LD2410S_VERSION + bool handle_version_(const uint8_t *values, uint16_t len); +#endif void count_dropped_frame_() { if (this->dropped_frames_ < UINT8_MAX) { this->dropped_frames_++; @@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { this->expected_len_ = 0; this->frame_type_ = FrameType::NONE; } - void run_init_sequence_(uint32_t now); + void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now); + void restart_init_(uint32_t at); + void run_sequence_(uint32_t now); + uint16_t current_command_() const; void send_command_(uint16_t command); - void publish_target_(bool target); + void publish_target_(bool target, uint16_t distance); +#ifdef USE_LD2410S_PARAMETERS + void publish_parameters_(); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number +#endif + + const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent uint32_t next_send_at_{0}; uint32_t last_frame_at_{0}; +#ifdef USE_LD2410S_PARAMETERS + uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed +#endif uint8_t rx_buffer_[RX_BUFFER_SIZE]; uint8_t rx_len_{0}; uint8_t expected_len_{0}; - uint8_t init_step_{0}; - uint8_t init_timeouts_{0}; + uint8_t sequence_len_{0}; + uint8_t step_{0}; + uint8_t step_timeouts_{0}; uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 FrameType frame_type_{FrameType::NONE}; bool awaiting_ack_{false}; bool rescanning_{false}; + bool init_done_{false}; +#ifdef USE_LD2410S_PARAMETERS + bool parameters_known_{false}; // the module answered the parameter read at least once + bool parameters_dirty_{false}; +#endif }; } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s_number.h b/esphome/components/ld2410s/ld2410s_number.h new file mode 100644 index 00000000000..bc24e300cae --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_number.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/number/number.h" + +namespace esphome::ld2410s { + +/// Edits one common parameter; scale converts the module's units to the number's. +class LD2410SNumber final : public number::Number, public Parented { + public: + LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {} + + void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); } + + protected: + void control(float value) override { + // Show the step the module got, not a value between two steps + const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte + if (this->parent_->set_parameter(this->parameter_, raw)) + this->publish_raw(raw); + } + + const Parameter parameter_; + const float scale_; +}; + +} // namespace esphome::ld2410s + +#endif // USE_NUMBER && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/ld2410s_select.h b/esphome/components/ld2410s/ld2410s_select.h new file mode 100644 index 00000000000..e2598b917a8 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_select.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/select/select.h" + +namespace esphome::ld2410s { + +class LD2410SSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override { + if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index]))) + this->publish_state(index); + } +}; + +} // namespace esphome::ld2410s + +#endif // USE_SELECT && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/number.py b/esphome/components/ld2410s/number.py new file mode 100644 index 00000000000..912e1397348 --- /dev/null +++ b/esphome/components/ld2410s/number.py @@ -0,0 +1,108 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_FREQUENCY, + ENTITY_CATEGORY_CONFIG, + ICON_PULSE, + ICON_TIMELAPSE, + UNIT_HERTZ, + UNIT_METER, + UNIT_SECOND, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency" +CONF_MAX_DISTANCE = "max_distance" +CONF_MIN_DISTANCE = "min_distance" +CONF_NO_DELAY = "no_delay" +CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency" + +LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number) + +# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz +GATE_WIDTH_M = 0.7 +FREQUENCY_SCALE = 0.1 +_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M} +_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5} + + +def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema: + return number.number_schema( + LD2410SNumber, + device_class=device_class, + entity_category=ENTITY_CATEGORY_CONFIG, + unit_of_measurement=unit, + icon=icon, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_MAX_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right" + ), + cv.Optional(CONF_MIN_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left" + ), + cv.Optional(CONF_NO_DELAY): _number_schema( + DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE + ), + cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), + cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_MAX_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE), + Parameter.PARAMETER_MAX_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_MIN_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE), + Parameter.PARAMETER_MIN_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_NO_DELAY, + partial(hub.set_number, Parameter.PARAMETER_NO_DELAY), + Parameter.PARAMETER_NO_DELAY, + 1, + min_value=1, + max_value=120, + step=1, + ) + await numbers( + CONF_STATUS_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY), + Parameter.PARAMETER_STATUS_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) + await numbers( + CONF_DISTANCE_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY), + Parameter.PARAMETER_DISTANCE_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) diff --git a/esphome/components/ld2410s/select.py b/esphome/components/ld2410s/select.py new file mode 100644 index 00000000000..4a1376b9bf2 --- /dev/null +++ b/esphome/components/ld2410s/select.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_RESPONSE_SPEED = "response_speed" + +LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_RESPONSE_SPEED): select.select_schema( + LD2410SSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon="mdi:speedometer", + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"] + ) diff --git a/esphome/components/ld2410s/sensor.py b/esphome/components/ld2410s/sensor.py new file mode 100644 index 00000000000..96eccc08f41 --- /dev/null +++ b/esphome/components/ld2410s/sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + DEVICE_CLASS_DISTANCE, + ICON_SIGNAL_DISTANCE_VARIANT, + STATE_CLASS_MEASUREMENT, + UNIT_CENTIMETER, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_SIGNAL_DISTANCE_VARIANT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) diff --git a/esphome/components/ld2410s/text_sensor.py b/esphome/components/ld2410s/text_sensor.py new file mode 100644 index 00000000000..d017d3f1a6c --- /dev/null +++ b/esphome/components/ld2410s/text_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_VERSION") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 072abe83742..1a5ab82d934 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -97,6 +97,8 @@ #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY +#define USE_LD2410S_PARAMETERS +#define USE_LD2410S_VERSION #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT diff --git a/tests/components/ld2410s/common-bare.yaml b/tests/components/ld2410s/common-bare.yaml new file mode 100644 index 00000000000..2f0e569bb98 --- /dev/null +++ b/tests/components/ld2410s/common-bare.yaml @@ -0,0 +1,28 @@ +ld2410s: + id: ld2410s_bare + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_bare + has_target: + name: Has Target + +# Entities of other platforms must not pull the parameter code into a bare hub +number: + - platform: template + name: Unrelated Number + optimistic: true + min_value: 0 + max_value: 10 + step: 1 + +select: + - platform: template + name: Unrelated Select + optimistic: true + options: ["a", "b"] + +text_sensor: + - platform: template + name: Unrelated Text diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml index 33b213d363c..5ae96497103 100644 --- a/tests/components/ld2410s/common.yaml +++ b/tests/components/ld2410s/common.yaml @@ -5,3 +5,31 @@ binary_sensor: - platform: ld2410s has_target: name: Presence + +sensor: + - platform: ld2410s + distance: + name: Distance + +text_sensor: + - platform: ld2410s + version: + name: Firmware + +number: + - platform: ld2410s + max_distance: + name: Max Distance + min_distance: + name: Min Distance + no_delay: + name: No Delay + status_reporting_frequency: + name: Status Reporting Frequency + distance_reporting_frequency: + name: Distance Reporting Frequency + +select: + - platform: ld2410s + response_speed: + name: Response Speed diff --git a/tests/components/ld2410s/test-bare.esp8266-ard.yaml b/tests/components/ld2410s/test-bare.esp8266-ard.yaml new file mode 100644 index 00000000000..bda2f21bb48 --- /dev/null +++ b/tests/components/ld2410s/test-bare.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common-bare.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml index 113e0881e6f..858a689b395 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -22,16 +22,58 @@ uart_mock: id: mock_uart baud_rate: 115200 auto_start: false + on_tx: + - then: + - lambda: |- + // Parameter read: the first answer stops after one value so the read is resent after the + // 300 ms timeout; the resend gets gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + static const std::vector PARAMETERS_READ = { + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, + 0x06, 0x00, 0x02, 0x00, 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01}; + static int reads = 0; + if (data != PARAMETERS_READ) { + return; + } + if (++reads == 1) { + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01}); + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01}); responses: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # The parameter read is answered by the on_tx lambda below: cut short once, then in full # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] # Config-mode end, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Setting the max distance to 7 m writes all six parameters inside a configuration-mode session + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] injections: # Short frame, state 2 (target), distance 300 -> presence on - delay: 100ms @@ -76,6 +118,26 @@ binary_sensor: has_target: name: "Has Target" +sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + distance: + name: "Distance" + +text_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + version: + name: "Version" + +number: + - platform: ld2410s + ld2410s_id: ld2410s_dev + max_distance: + name: "Max Distance" + no_delay: + name: "No Delay" + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml index 5f06985c7b5..57b66f0d9dd 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -26,6 +26,25 @@ uart_mock: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # Parameter read, answered with gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x06, 0x00, 0x02, 0x00, + 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py index 8cd9b4b8dd1..44b808cd4c7 100644 --- a/tests/integration/test_uart_mock_ld2410s.py +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -1,9 +1,13 @@ """Integration tests for the LD2410S component with mock UART. test_uart_mock_ld2410s: - 1. Init sequence - config-mode start, output-mode switch and config-mode end go - out in order, each after the previous one is acknowledged, then the - component logs "Setup done" and clears its warning + 1. Init sequence - config-mode start, version read, parameter read, output-mode + switch and config-mode end go out in order, each after the previous one is + acknowledged, then the component logs "Setup done" and clears its warning; + the version and parameter answers are parsed and logged + 1b. Setting the max distance number to 7 m writes all six parameters (gate 10 + plus the values read at init) inside a configuration-mode session, and the + number reports 7 m 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard data frame 3. Resync - a noise byte or a partial header directly before a short frame @@ -34,15 +38,40 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import APIClient, ButtonInfo +from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState import pytest from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +VERSION_READ_TX = "FD:FC:FB:FA:02:00:00:00:04:03:02:01" +PARAMETERS_READ_TX = ( + "FD:FC:FB:FA:0E:00:71:00:05:00:0A:00:06:00:02:00:0C:00:0B:00:04:03:02:01" +) OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" +INIT_TX = [ + CONFIG_START_TX, + VERSION_READ_TX, + # The fixture cuts the first answer short, so the read goes out again + PARAMETERS_READ_TX, + PARAMETERS_READ_TX, + OUTPUT_MODE_TX, + CONFIG_END_TX, +] +# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5 +PARAMETERS_WRITE_TX = ( + "FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:" + "0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01" +) +WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX] +# Logged once the version and parameter answers are parsed +PARSED_ANSWERS = [ + "Firmware 1.2.3", + "Parameter answer too short (4 bytes)", + "Parameters: gates 0 to 16, no delay 10 s", +] ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -119,12 +148,17 @@ async def test_uart_mock_ld2410s( setup_done = loop.create_future() warning_cleared = loop.create_future() dropped_states: list[str] = [] + parsed_answers: list[str] = [] + write_done = loop.create_future() def line_callback(line: str) -> None: if (frame := _tx_frame(line)) is not None: tx_frames.append(frame) + if len(tx_frames) >= 9 and not write_done.done(): + write_done.set_result(True) if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): setup_done.set_result(True) + parsed_answers.extend(answer for answer in PARSED_ANSWERS if answer in line) if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): warning_cleared.set_result(True) if "Dropping frame with impossible state " in line: @@ -133,8 +167,15 @@ async def test_uart_mock_ld2410s( ) collector = SensorStateCollector( - sensor_names=[], binary_sensor_names=["has_target"] + sensor_names=["distance"], binary_sensor_names=["has_target"] ) + number_states: list[float] = [] + + def on_state(state: EntityState) -> None: + collector.on_state(state) + if isinstance(state, NumberState) and state.key == max_distance_key: + number_states.append(state.state) + all_presence = collector.add_waiter( lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) ) @@ -148,17 +189,41 @@ async def test_uart_mock_ld2410s( except TimeoutError: pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") - assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + assert tx_frames[:6] == INIT_TX + assert parsed_answers == PARSED_ANSWERS - await _start_scenario(client, collector) + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + max_distance_key = require_entity(entities, "max_distance", NumberInfo).key + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + # The values read from the module: gate 16 and 10 s + no_delay_key = require_entity(entities, "no_delay", NumberInfo).key + initial = initial_state_helper.initial_states + assert initial[max_distance_key].state == pytest.approx(11.2) + assert initial[no_delay_key].state == pytest.approx(10) + + client.number_command(max_distance_key, 7.0) + try: + await asyncio.wait_for(write_done, 5.0) + except TimeoutError: + pytest.fail(f"Parameter write not sent. TX frames: {tx_frames}") + assert tx_frames[6:9] == WRITE_TX, tx_frames + assert number_states == [pytest.approx(7.0)] + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) await _wait_for_presence(collector, all_presence) # Let any unexpected extra update arrive before comparing await asyncio.sleep(0.3) assert collector.binary_states["has_target"] == EXPECTED_PRESENCE assert dropped_states == EXPECTED_DROPPED_STATES + # A target 300 cm away, then none: the distance follows the presence + assert collector.sensor_states["distance"][:2] == [300, 0] # Data frames must not restart the init sequence - assert len(tx_frames) == 3, tx_frames + assert len(tx_frames) == 9, tx_frames @pytest.mark.asyncio From 74e96b7da6b3986ea9d20a8b2338f9ca836ea405 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:21:00 -0500 Subject: [PATCH 24/66] [INA3221] Add optional flags to expose all functionality (#16309) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ina3221/ina3221.cpp | 136 ++++++++--- esphome/components/ina3221/ina3221.h | 25 +- esphome/components/ina3221/sensor.py | 224 +++++++++++++++--- esphome/core/defines.h | 2 + tests/components/ina3221/common.yaml | 29 +++ tests/components/ina3221/test.esp32-idf.yaml | 3 +- .../components/ina3221/test.esp8266-ard.yaml | 3 +- tests/components/ina3221/test.rp2040-ard.yaml | 3 +- 8 files changed, 352 insertions(+), 73 deletions(-) diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index f3647c4d1ca..e66e2456767 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -13,6 +13,24 @@ static const uint8_t INA3221_REGISTER_CHANNEL2_SHUNT_VOLTAGE = 0x03; static const uint8_t INA3221_REGISTER_CHANNEL2_BUS_VOLTAGE = 0x04; static const uint8_t INA3221_REGISTER_CHANNEL3_SHUNT_VOLTAGE = 0x05; static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; +#ifdef USE_INA3221_ALERT_LIMITS +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT = 0x07; +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_WARNING_ALERT = 0x0A; +#endif +#ifdef USE_INA3221_SUMMATION +static constexpr uint8_t INA3221_REGISTER_SHUNT_VOLTAGE_SUM = 0x0D; +static constexpr uint8_t INA3221_REGISTER_MASK_ENABLE = 0x0F; +#endif + +// Configuration register: bit 15 resets the chip, bits 14..12 enable channels 1..3 (the same bit +// positions select channels for summation in the mask/enable register) and bits 2..0 set the mode. +// Code generation fills in the averaging and conversion time fields. +static constexpr uint16_t INA3221_CONFIG_RESET = 0x8000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL1_ENABLE = 0x4000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL_MASK = 0x7000; +static constexpr uint16_t INA3221_CONFIG_MODE_MASK = 0x0007; + +static constexpr uint32_t READ_TIMEOUT_ID = 0; // Addresses: // A0 = GND -> 0x40 @@ -21,37 +39,38 @@ static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; // A0 = SCL -> 0x43 void INA3221Component::setup() { - // Config Register - // 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset) - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, 0x8000)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) { this->mark_failed(); return; } delay(1); - uint16_t config = 0; - // 0b0xxx000000000000 << 12 Channel Enables (1 -> ON) - if (this->channels_[0].exists()) { - config |= 0b0100000000000000; - } - if (this->channels_[1].exists()) { - config |= 0b0010000000000000; - } - if (this->channels_[2].exists()) { - config |= 0b0001000000000000; - } - // 0b0000xxx000000000 << 9 Averaging Mode (0 -> 1 sample, 111 -> 1024 samples) - config |= 0b0000000000000000; - // 0b0000000xxx000000 << 6 Bus Voltage Conversion time (100 -> 1.1ms, 111 -> 8.244 ms) - config |= 0b0000000111000000; - // 0b0000000000xxx000 << 3 Shunt Voltage Conversion time (same as above) - config |= 0b0000000000111000; - // 0b0000000000000xxx << 0 Operating mode (111 -> Shunt and bus, continuous) - config |= 0b0000000000000111; - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, config)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { this->mark_failed(); return; } + +#ifdef USE_INA3221_ALERT_LIMITS + for (uint8_t i = 0; i < 3; i++) { + const INA3221Channel &channel = this->channels_[i]; + if ((channel.critical_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT + i, channel.critical_limit_)) || + (channel.warning_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_WARNING_ALERT + i, channel.warning_limit_))) { + this->mark_failed(); + return; + } + } +#endif + +#ifdef USE_INA3221_SUMMATION + // Sum the shunt voltages of every enabled channel + if (this->sum_shunt_voltage_sensor_ != nullptr && + !this->write_byte_16(INA3221_REGISTER_MASK_ENABLE, this->config_ & INA3221_CONFIG_CHANNEL_MASK)) { + this->mark_failed(); + return; + } +#endif } void INA3221Component::dump_config() { @@ -61,6 +80,10 @@ void INA3221Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Config register: 0x%04X", this->config_); + if (this->single_shot_wait_ms_ != 0) { + ESP_LOGCONFIG(TAG, " Single-shot conversion: %u ms", this->single_shot_wait_ms_); + } LOG_SENSOR(" ", "Bus Voltage #1", this->channels_[0].bus_voltage_sensor_); LOG_SENSOR(" ", "Shunt Voltage #1", this->channels_[0].shunt_voltage_sensor_); @@ -74,6 +97,11 @@ void INA3221Component::dump_config() { LOG_SENSOR(" ", "Shunt Voltage #3", this->channels_[2].shunt_voltage_sensor_); LOG_SENSOR(" ", "Current #3", this->channels_[2].current_sensor_); LOG_SENSOR(" ", "Power #3", this->channels_[2].power_sensor_); +#ifdef USE_INA3221_SUMMATION + LOG_SENSOR(" ", "Sum Shunt Voltage", this->sum_shunt_voltage_sensor_); + LOG_SENSOR(" ", "Sum Current", this->sum_current_sensor_); + LOG_SENSOR(" ", "Sum Power", this->sum_power_sensor_); +#endif } inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel * 2; } @@ -81,11 +109,36 @@ inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel inline uint8_t ina3221_shunt_voltage_register(int channel) { return 0x01 + channel * 2; } void INA3221Component::update() { + if (this->single_shot_wait_ms_ == 0) { + this->read_data_(); + return; + } + // In single-shot mode, writing the configuration register starts one conversion of every channel + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TIMEOUT_ID, this->single_shot_wait_ms_, [this]() { this->read_data_(); }); +} + +void INA3221Component::read_data_() { +#ifdef USE_INA3221_SUMMATION + // The software sums need every enabled channel's readings, not only the ones with their own sensors + const bool sum_all = this->sum_current_sensor_ != nullptr || this->sum_power_sensor_ != nullptr; + float total_current_a = 0.0f; + float total_power_w = 0.0f; +#else + constexpr bool sum_all = false; +#endif + for (int i = 0; i < 3; i++) { + if ((this->config_ & (INA3221_CONFIG_CHANNEL1_ENABLE >> i)) == 0) { + continue; + } INA3221Channel &channel = this->channels_[i]; float bus_voltage_v = NAN, current_a = NAN; uint16_t raw; - if (channel.should_measure_bus_voltage()) { + if (sum_all || channel.should_measure_bus_voltage()) { if (!this->read_byte_16(ina3221_bus_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -94,7 +147,7 @@ void INA3221Component::update() { if (channel.bus_voltage_sensor_ != nullptr) channel.bus_voltage_sensor_->publish_state(bus_voltage_v); } - if (channel.should_measure_shunt_voltage()) { + if (sum_all || channel.should_measure_shunt_voltage()) { if (!this->read_byte_16(ina3221_shunt_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -109,17 +162,42 @@ void INA3221Component::update() { if (channel.power_sensor_ != nullptr) { channel.power_sensor_->publish_state(bus_voltage_v * current_a); } +#ifdef USE_INA3221_SUMMATION + if (sum_all) { + total_current_a += current_a; + total_power_w += bus_voltage_v * current_a; + } +#endif } + +#ifdef USE_INA3221_SUMMATION + if (this->sum_shunt_voltage_sensor_ != nullptr) { + uint16_t raw; + if (!this->read_byte_16(INA3221_REGISTER_SHUNT_VOLTAGE_SUM, &raw)) { + this->status_set_warning(); + return; + } + // 40 uV per step, held in bits 15..1 + this->sum_shunt_voltage_sensor_->publish_state(int16_t(raw) * 40.0f / 2.0f / 1000000.0f); + } + if (this->sum_current_sensor_ != nullptr) { + this->sum_current_sensor_->publish_state(total_current_a); + } + if (this->sum_power_sensor_ != nullptr) { + this->sum_power_sensor_->publish_state(total_power_w); + } +#endif +} + +void INA3221Component::on_powerdown() { + // Mode 0 powers the chip down; setup() resets it on the next boot + this->write_byte_16(INA3221_REGISTER_CONFIG, static_cast(this->config_ & ~INA3221_CONFIG_MODE_MASK)); } void INA3221Component::set_shunt_resistance(int channel, float resistance_ohm) { this->channels_[channel].shunt_resistance_ = resistance_ohm; } -bool INA3221Component::INA3221Channel::exists() { - return this->bus_voltage_sensor_ != nullptr || this->shunt_voltage_sensor_ != nullptr || - this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; -} bool INA3221Component::INA3221Channel::should_measure_shunt_voltage() { return this->shunt_voltage_sensor_ != nullptr || this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; } diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 48226c743a9..759dd6c13da 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -7,10 +7,17 @@ namespace esphome::ina3221 { class INA3221Component final : public PollingComponent, public i2c::I2CDevice { +#ifdef USE_INA3221_SUMMATION + SUB_SENSOR(sum_shunt_voltage) + SUB_SENSOR(sum_current) + SUB_SENSOR(sum_power) +#endif + public: void setup() override; void dump_config() override; void update() override; + void on_powerdown() override; void set_bus_voltage_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].bus_voltage_sensor_ = obj; } void set_shunt_voltage_sensor(int channel, sensor::Sensor *obj) { @@ -19,19 +26,35 @@ class INA3221Component final : public PollingComponent, public i2c::I2CDevice { void set_current_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].current_sensor_ = obj; } void set_power_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].power_sensor_ = obj; } void set_shunt_resistance(int channel, float resistance_ohm); + /// Configuration register value built by code generation: channel enables, averaging, conversion times, mode + void set_config_register(uint16_t config) { this->config_ = config; } + /// Time one single-shot conversion of every enabled channel takes; zero means continuous mode + void set_single_shot_wait_ms(uint16_t wait_ms) { this->single_shot_wait_ms_ = wait_ms; } +#ifdef USE_INA3221_ALERT_LIMITS + void set_warning_limit(int channel, uint16_t reg) { this->channels_[channel].warning_limit_ = reg; } + void set_critical_limit(int channel, uint16_t reg) { this->channels_[channel].critical_limit_ = reg; } +#endif protected: + void read_data_(); + struct INA3221Channel { float shunt_resistance_{0.1f}; sensor::Sensor *bus_voltage_sensor_{nullptr}; sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; +#ifdef USE_INA3221_ALERT_LIMITS + // Alert limit register values; zero keeps the chip default, which never trips + uint16_t warning_limit_{0}; + uint16_t critical_limit_{0}; +#endif - bool exists(); bool should_measure_shunt_voltage(); bool should_measure_bus_voltage(); } channels_[3]; + uint16_t config_{0}; + uint16_t single_shot_wait_ms_{0}; }; } // namespace esphome::ina3221 diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index db8dad2f542..e872b73b09f 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -1,5 +1,8 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -8,6 +11,7 @@ from esphome.const import ( CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, + CONF_UPDATE_INTERVAL, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, @@ -23,69 +27,195 @@ DEPENDENCIES = ["i2c"] CONF_CHANNEL_1 = "channel_1" CONF_CHANNEL_2 = "channel_2" CONF_CHANNEL_3 = "channel_3" +CONF_CONTINUOUS_MODE = "continuous_mode" +CONF_WARNING_CURRENT_LIMIT = "warning_current_limit" +CONF_CRITICAL_CURRENT_LIMIT = "critical_current_limit" +CONF_SUM_SHUNT_VOLTAGE = "sum_shunt_voltage" +CONF_SUM_CURRENT = "sum_current" +CONF_SUM_POWER = "sum_power" + +CHANNELS = (CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3) + +# Configuration register layout: channel enables in bits 14..12, averaging in 11..9, +# bus conversion time in 8..6, shunt conversion time in 5..3, operating mode in 2..0 +CHANNEL_ENABLE_BITS = (1 << 14, 1 << 13, 1 << 12) +ADC_AVERAGING_SHIFT = 9 +ADC_TIME_BUS_SHIFT = 6 +ADC_TIME_SHUNT_SHIFT = 3 +MODE_SINGLE_SHOT = 0b011 +MODE_CONTINUOUS = 0b111 + +# Register field value by sample count and by conversion time in microseconds +ADC_AVERAGING = {1: 0, 4: 1, 16: 2, 64: 3, 128: 4, 256: 5, 512: 6, 1024: 7} +ADC_TIMES_US = {140: 0, 204: 1, 332: 2, 588: 3, 1100: 4, 2116: 5, 4156: 6, 8244: 7} + +# Alert limits are shunt voltages in 40 uV steps, held in bits 15..3 +ALERT_LIMIT_LSB_V = 40e-6 +ALERT_LIMIT_MAX_V = 0x0FFF * ALERT_LIMIT_LSB_V +# Headroom on top of the computed conversion time before the results are read +SINGLE_SHOT_MARGIN_MS = 5 ina3221_ns = cg.esphome_ns.namespace("ina3221") INA3221Component = ina3221_ns.class_( "INA3221Component", cg.PollingComponent, i2c.I2CDevice ) -INA3221_CHANNEL_SCHEMA = cv.Schema( - { - cv.Optional(CONF_BUS_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( - cv.resistance, cv.Range(min=0.0, max=32.0) - ), - } +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_LIMIT_SCHEMA = cv.All(cv.current, cv.Range(min=0.0, min_included=False)) + + +def validate_adc_time(value: Any) -> int: + value = cv.positive_time_period_microseconds(value).total_microseconds + return cv.one_of(*ADC_TIMES_US, int=True)(value) + + +def validate_alert_limits(config: ConfigType) -> ConfigType: + shunt = config[CONF_SHUNT_RESISTANCE] + for key in (CONF_WARNING_CURRENT_LIMIT, CONF_CRITICAL_CURRENT_LIMIT): + if (limit := config.get(key)) is None: + continue + if limit * shunt > ALERT_LIMIT_MAX_V: + raise cv.Invalid( + f"{key} times shunt_resistance exceeds the " + f"{ALERT_LIMIT_MAX_V * 1000:.1f} mV alert range", + path=[key], + ) + if alert_limit_register(limit, shunt) == 0: + raise cv.Invalid( + f"{key} times shunt_resistance is below the " + f"{ALERT_LIMIT_LSB_V * 1e6:.0f} µV alert resolution", + path=[key], + ) + return config + + +INA3221_CHANNEL_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_BUS_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_POWER): POWER_SCHEMA, + cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( + cv.resistance, cv.Range(min=0.0, max=32.0) + ), + cv.Optional(CONF_WARNING_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + cv.Optional(CONF_CRITICAL_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key( + CONF_BUS_VOLTAGE, + CONF_SHUNT_VOLTAGE, + CONF_CURRENT, + CONF_POWER, + CONF_WARNING_CURRENT_LIMIT, + CONF_CRITICAL_CURRENT_LIMIT, + ), + validate_alert_limits, ) -CONFIG_SCHEMA = ( + +def adc_times_us(config: ConfigType) -> tuple[int, int]: + """Return the bus and shunt conversion times in microseconds.""" + adc_time = config[CONF_ADC_TIME] + if isinstance(adc_time, dict): + return adc_time[CONF_BUS_VOLTAGE], adc_time[CONF_SHUNT_VOLTAGE] + return adc_time, adc_time + + +def single_shot_wait_ms(config: ConfigType) -> int: + """Time one conversion of every enabled channel takes, plus headroom.""" + bus_us, shunt_us = adc_times_us(config) + channels = sum(1 for channel in CHANNELS if channel in config) + total_us = (bus_us + shunt_us) * config[CONF_ADC_AVERAGING] * channels + return -(-total_us // 1000) + SINGLE_SHOT_MARGIN_MS + + +def validate_single_shot_interval(config: ConfigType) -> ConfigType: + if config[CONF_CONTINUOUS_MODE]: + return config + wait_ms = single_shot_wait_ms(config) + if config[CONF_UPDATE_INTERVAL].total_milliseconds <= wait_ms: + raise cv.Invalid( + f"A single-shot conversion of all channels takes {wait_ms} ms, which does " + f"not fit in the update_interval; lower {CONF_ADC_AVERAGING} or " + f"{CONF_ADC_TIME}, or raise the interval", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(INA3221Component), cv.Optional(CONF_CHANNEL_1): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_2): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_3): INA3221_CHANNEL_SCHEMA, + cv.Optional(CONF_CONTINUOUS_MODE, default=True): cv.boolean, + cv.Optional(CONF_ADC_AVERAGING, default=1): cv.one_of( + *ADC_AVERAGING, int=True + ), + cv.Optional(CONF_ADC_TIME, default="8244us"): cv.Any( + validate_adc_time, + cv.Schema( + { + cv.Required(CONF_BUS_VOLTAGE): validate_adc_time, + cv.Required(CONF_SHUNT_VOLTAGE): validate_adc_time, + } + ), + ), + cv.Optional(CONF_SUM_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SUM_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_SUM_POWER): POWER_SCHEMA, } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_least_one_key(*CHANNELS), + validate_single_shot_interval, ) +def alert_limit_register(current_a: float, shunt_ohm: float) -> int: + return round(current_a * shunt_ohm / ALERT_LIMIT_LSB_V) << 3 + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - for i, channel in enumerate([CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3]): - if channel not in config: + bus_us, shunt_us = adc_times_us(config) + register = ( + ADC_AVERAGING[config[CONF_ADC_AVERAGING]] << ADC_AVERAGING_SHIFT + | ADC_TIMES_US[bus_us] << ADC_TIME_BUS_SHIFT + | ADC_TIMES_US[shunt_us] << ADC_TIME_SHUNT_SHIFT + | (MODE_CONTINUOUS if config[CONF_CONTINUOUS_MODE] else MODE_SINGLE_SHOT) + ) + for i, channel in enumerate(CHANNELS): + if (conf := config.get(channel)) is None: continue - conf = config[channel] - if CONF_SHUNT_RESISTANCE in conf: - cg.add(var.set_shunt_resistance(i, conf[CONF_SHUNT_RESISTANCE])) + register |= CHANNEL_ENABLE_BITS[i] + shunt = conf[CONF_SHUNT_RESISTANCE] + cg.add(var.set_shunt_resistance(i, shunt)) if CONF_BUS_VOLTAGE in conf: sens = await sensor.new_sensor(conf[CONF_BUS_VOLTAGE]) cg.add(var.set_bus_voltage_sensor(i, sens)) @@ -98,3 +228,23 @@ async def to_code(config: ConfigType) -> None: if CONF_POWER in conf: sens = await sensor.new_sensor(conf[CONF_POWER]) cg.add(var.set_power_sensor(i, sens)) + for key, setter in ( + (CONF_WARNING_CURRENT_LIMIT, var.set_warning_limit), + (CONF_CRITICAL_CURRENT_LIMIT, var.set_critical_limit), + ): + if (limit := conf.get(key)) is not None: + cg.add_define("USE_INA3221_ALERT_LIMITS") + cg.add(setter(i, alert_limit_register(limit, shunt))) + cg.add(var.set_config_register(register)) + if not config[CONF_CONTINUOUS_MODE]: + cg.add(var.set_single_shot_wait_ms(single_shot_wait_ms(config))) + + if any( + key in config + for key in (CONF_SUM_SHUNT_VOLTAGE, CONF_SUM_CURRENT, CONF_SUM_POWER) + ): + cg.add_define("USE_INA3221_SUMMATION") + sensors = sensor.sub_sensors(config) + await sensors(CONF_SUM_SHUNT_VOLTAGE, var.set_sum_shunt_voltage_sensor) + await sensors(CONF_SUM_CURRENT, var.set_sum_current_sensor) + await sensors(CONF_SUM_POWER, var.set_sum_power_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1a5ab82d934..6f2a499730c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -91,6 +91,8 @@ #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_BLE_STATE_CALLBACK +#define USE_INA3221_ALERT_LIMITS +#define USE_INA3221_SUMMATION #define USE_INFRARED #define USE_IR_RF #define USE_IR_RF_TRANSMIT_COMPLETE diff --git a/tests/components/ina3221/common.yaml b/tests/components/ina3221/common.yaml index 570d1b0a12e..b836333ad87 100644 --- a/tests/components/ina3221/common.yaml +++ b/tests/components/ina3221/common.yaml @@ -13,3 +13,32 @@ sensor: shunt_voltage: name: INA3221 Channel 1 Shunt Voltage update_interval: 15s + - platform: ina3221 + i2c_id: i2c_bus + address: 0x41 + continuous_mode: false + adc_averaging: 4 + adc_time: + bus_voltage: 2116us + shunt_voltage: 1.1ms + channel_1: + shunt_resistance: 0.05 ohm + current: + name: INA3221 B Channel 1 Current + warning_current_limit: 1A + critical_current_limit: 2A + channel_2: + shunt_resistance: 0.05 ohm + bus_voltage: + name: INA3221 B Channel 2 Bus Voltage + channel_3: + shunt_resistance: 0.05 ohm + power: + name: INA3221 B Channel 3 Power + sum_shunt_voltage: + name: INA3221 B Sum Shunt Voltage + sum_current: + name: INA3221 B Sum Current + sum_power: + name: INA3221 B Sum Power + update_interval: 15s diff --git a/tests/components/ina3221/test.esp32-idf.yaml b/tests/components/ina3221/test.esp32-idf.yaml index b47e39c3898..9ebc2589968 100644 --- a/tests/components/ina3221/test.esp32-idf.yaml +++ b/tests/components/ina3221/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.esp8266-ard.yaml b/tests/components/ina3221/test.esp8266-ard.yaml index 4a98b9388ab..c0607404540 100644 --- a/tests/components/ina3221/test.esp8266-ard.yaml +++ b/tests/components/ina3221/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.rp2040-ard.yaml b/tests/components/ina3221/test.rp2040-ard.yaml index 319a7c71a65..257e90c0e62 100644 --- a/tests/components/ina3221/test.rp2040-ard.yaml +++ b/tests/components/ina3221/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml From f829447b2e711f1c8b0d6991521542eefd211407 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:49 -0400 Subject: [PATCH 25/66] [tmp102] Add number entities for runtime thermostat thresholds (#20596) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tmp102/__init__.py | 41 +++- esphome/components/tmp102/number.py | 87 ++++++++ esphome/components/tmp102/sensor.py | 15 +- esphome/components/tmp102/tmp102.cpp | 119 ++++++++++- esphome/components/tmp102/tmp102.h | 43 ++++ esphome/core/defines.h | 1 + .../tmp102/config/duplicate_numbers.yaml | 25 +++ .../tmp102/config/numbers.yaml | 24 +++ .../tmp102/config/numbers_inherit.yaml | 25 +++ .../tmp102/config/numbers_initial_value.yaml | 22 ++ tests/component_tests/tmp102/test_config.py | 55 +++++ tests/components/tmp102/__init__.py | 10 +- tests/components/tmp102/common.yaml | 10 + tests/components/tmp102/test_tmp102.cpp | 190 +++++++++++++++++- 14 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 esphome/components/tmp102/number.py create mode 100644 tests/component_tests/tmp102/config/duplicate_numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_inherit.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_initial_value.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 077dd03ccc4..9b4cf6680a4 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,10 +1,14 @@ """Shared TMP102 types and configuration validation.""" +from dataclasses import dataclass, field +import math + import esphome.codegen as cg from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import CONF_PLATFORM +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -16,6 +20,7 @@ tmp102_ns = cg.esphome_ns.namespace("tmp102") TMP102Component = tmp102_ns.class_( "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor ) +TMP102LimitType = tmp102_ns.enum("TMP102LimitType") CONF_EXTENDED_MODE = "extended_mode" CONF_ONE_SHOT_MODE = "one_shot_mode" @@ -34,10 +39,40 @@ TMP102_CONVERSION_FACTOR = 0.0625 TMP102_DEFAULT_THIGH = 80.0 TMP102_DEFAULT_TLOW = 75.0 +TMP102_MIN_TEMPERATURE = -55.0 +TMP102_MAX_TEMPERATURE = 127.9375 +TMP102_MAX_TEMPERATURE_EXTENDED = 150.0 + + +def max_temperature(extended: bool) -> float: + return TMP102_MAX_TEMPERATURE_EXTENDED if extended else TMP102_MAX_TEMPERATURE + + +@dataclass +class TMP102Data: + # Extended mode of each sensor that has threshold numbers, keyed by sensor ID. + number_parents: dict[str, bool] = field(default_factory=dict) + + +def _get_data() -> TMP102Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TMP102Data() + return CORE.data[DOMAIN] + + +def add_number_parent(parent_id: object, extended: bool) -> None: + _get_data().number_parents[str(parent_id)] = extended + + +def number_parent_extended(parent_id: object) -> bool | None: + """Return the extended mode of a sensor with threshold numbers, or None without numbers.""" + return _get_data().number_parents.get(str(parent_id)) + def encode_temperature(temperature: float, extended: bool) -> int: """Encode a temperature as a TMP102 limit-register word.""" - steps = round(temperature / TMP102_CONVERSION_FACTOR) + scaled = temperature / TMP102_CONVERSION_FACTOR + steps = int(scaled + math.copysign(0.5, scaled)) return (steps << (3 if extended else 4)) & 0xFFFF @@ -56,8 +91,8 @@ def build_configuration(config: ConfigType) -> int: def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: extended = config.get(CONF_EXTENDED_MODE, False) - t_min = -55.0 - t_max = 150.0 if extended else 127.9375 + t_min = TMP102_MIN_TEMPERATURE + t_max = max_temperature(extended) high = config.get(CONF_TEMPERATURE_HIGH) low = config.get(CONF_TEMPERATURE_LOW) for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): diff --git a/esphome/components/tmp102/number.py b/esphome/components/tmp102/number.py new file mode 100644 index 00000000000..e353a8d1183 --- /dev/null +++ b/esphome/components/tmp102/number.py @@ -0,0 +1,87 @@ +"""Adjustable TMP102 thermostat thresholds.""" + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_RESTORE_VALUE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_CONFIG, + UNIT_CELSIUS, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( + CONF_EXTENDED_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_TMP102_ID, + TMP102_CONVERSION_FACTOR, + TMP102_MIN_TEMPERATURE, + TMP102Component, + TMP102LimitType, + add_number_parent, + max_temperature, + number_parent_extended, + tmp102_ns, + validate_unique_child, +) + +TMP102LimitNumber = tmp102_ns.class_( + "TMP102LimitNumber", number.Number, cg.Parented.template(TMP102Component) +) + +_LIMIT_SCHEMA = number.number_schema( + TMP102LimitNumber, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend({cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean}) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + cv.Optional(CONF_TEMPERATURE_HIGH): _LIMIT_SCHEMA, + cv.Optional(CONF_TEMPERATURE_LOW): _LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE_HIGH, CONF_TEMPERATURE_LOW), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + validate_unique_child(config, "number") + full = fv.full_config.get() + parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1]) + add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_NUMBER") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + limits = { + "min_value": TMP102_MIN_TEMPERATURE, + "max_value": max_temperature(number_parent_extended(config[CONF_TMP102_ID])), + "step": TMP102_CONVERSION_FACTOR, + } + numbers = number.sub_numbers(config, parent=parent) + if var := await numbers( + CONF_TEMPERATURE_HIGH, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_HIGH, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_HIGH][CONF_RESTORE_VALUE])) + if var := await numbers( + CONF_TEMPERATURE_LOW, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_LOW, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_LOW][CONF_RESTORE_VALUE])) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index fbfed7444d6..c1eda11815a 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -5,6 +5,7 @@ from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( + CONF_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -28,6 +29,7 @@ from . import ( TMP102Component, build_configuration, encode_temperature, + number_parent_extended, validate_tmp102_thresholds, ) @@ -66,17 +68,20 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if not any(key in config for key in _ADVANCED_SCHEMA): + has_numbers = number_parent_extended(config[CONF_ID]) is not None + if not has_numbers and not any(key in config for key in _ADVANCED_SCHEMA): return extended = config.get(CONF_EXTENDED_MODE, False) - high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) - low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) cg.add_define("USE_TMP102_CONFIGURE") cg.add( var.set_configuration( build_configuration(config), - encode_temperature(high, extended), - encode_temperature(low, extended), + encode_temperature( + config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH), extended + ), + encode_temperature( + config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW), extended + ), ) ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 09b29dd7ef4..bb82975dbc3 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); @@ -18,6 +20,9 @@ static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; #ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +#ifdef USE_TMP102_NUMBER +static_assert(TMP102_REGISTER_LOW_LIMIT + TMP102_LIMIT_HIGH == TMP102_REGISTER_HIGH_LIMIT); +#endif static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; @@ -31,11 +36,39 @@ static float decode_temperature(uint16_t raw, bool extended) { return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; } +#ifdef USE_TMP102_NUMBER +static uint16_t encode_temperature(float temperature, bool extended) { + const int16_t steps = static_cast(std::lround(temperature / TMP102_CONVERSION_FACTOR)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); +} +#endif + void TMP102Component::setup() { if (!this->configure_) return; - if (!this->write_configuration_()) +#ifdef USE_TMP102_NUMBER + float restored[2]{}; + this->prepare_number_limits_(restored); +#endif + if (!this->write_configuration_()) { this->mark_failed(); + return; + } +#ifdef USE_TMP102_NUMBER + bool saved = true; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + const float value = this->get_limit_temperature_(static_cast(limit)); + if (value != restored[limit]) + saved &= control->save_value_(value); + control->publish_state(value); + } + if (!saved) { + ESP_LOGW(TAG, "Failed to save threshold preferences"); + } +#endif } #endif @@ -75,6 +108,10 @@ void TMP102Component::dump_config() { this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), decode_temperature(this->low_limit_, extended)); +#ifdef USE_TMP102_NUMBER + LOG_NUMBER(" ", "High Limit", this->limit_controls_[TMP102_LIMIT_HIGH]); + LOG_NUMBER(" ", "Low Limit", this->limit_controls_[TMP102_LIMIT_LOW]); +#endif if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); } @@ -185,6 +222,55 @@ void TMP102Component::read_temperature_() { #endif this->status_clear_warning(); } + +#ifdef USE_TMP102_NUMBER +void TMP102Component::set_limit_control(TMP102LimitNumber *number) { + this->limit_controls_[number->limit_type_] = number; +} + +void TMP102Component::prepare_number_limits_(float *restored) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + raw[limit] = encode_temperature(restored[limit], extended); + } + if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { + ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); + return; + } + this->low_limit_ = raw[TMP102_LIMIT_LOW]; + this->high_limit_ = raw[TMP102_LIMIT_HIGH]; +} + +float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { + const uint16_t raw = limit == TMP102_LIMIT_HIGH ? this->high_limit_ : this->low_limit_; + return decode_temperature(raw, this->config_ & TMP102_EXTENDED_MODE_BIT); +} + +bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temperature) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + const uint16_t raw = encode_temperature(temperature, extended); + const bool high = limit == TMP102_LIMIT_HIGH; + const uint16_t other = high ? this->low_limit_ : this->high_limit_; + if (high ? static_cast(raw) < static_cast(other) + : static_cast(raw) > static_cast(other)) { + ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", + high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), + high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + return false; + } + if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { + this->status_set_warning(); + return false; + } + (high ? this->high_limit_ : this->low_limit_) = raw; + return true; +} +#endif #endif #ifdef USE_TMP102_BINARY_SENSOR @@ -207,4 +293,35 @@ bool TMP102Component::read_alert_state_() { } #endif +#ifdef USE_TMP102_NUMBER +float TMP102LimitNumber::load_value_(float fallback) { + float value = fallback; + if (this->restore_value_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&value); + } + if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { + ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); + value = fallback; + } + return value; +} + +bool TMP102LimitNumber::save_value_(float value) { return !this->restore_value_ || this->pref_.save(&value); } + +void TMP102LimitNumber::control(float value) { + // A chip that failed setup holds nothing the entity could show + if (this->parent_->is_failed()) { + ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + return; + } + const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); + value = this->parent_->get_limit_temperature_(this->limit_type_); + this->publish_state(value); + if (accepted && !this->save_value_(value)) { + ESP_LOGW(TAG, "Failed to save threshold preference"); + } +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index caf75e19be7..d88dde78344 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,12 +6,26 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #include "esphome/components/i2c/i2c.h" +#ifdef USE_TMP102_NUMBER +#include "esphome/components/number/number.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#endif #include "esphome/components/sensor/sensor.h" #include namespace esphome::tmp102 { +#ifdef USE_TMP102_NUMBER +enum TMP102LimitType : uint8_t { + TMP102_LIMIT_LOW = 0, + TMP102_LIMIT_HIGH = 1, +}; + +class TMP102LimitNumber; +#endif + class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: #ifdef USE_TMP102_BINARY_SENSOR @@ -25,6 +39,9 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu this->low_limit_ = low_limit; this->configure_ = true; } +#ifdef USE_TMP102_NUMBER + void set_limit_control(TMP102LimitNumber *number); +#endif #endif void dump_config() override; void update() override; @@ -46,6 +63,14 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu bool check_configuration_(); void read_temperature_(); void update_configured_(); +#ifdef USE_TMP102_NUMBER + friend class TMP102LimitNumber; + bool set_limit_temperature_(TMP102LimitType limit, float temperature); + float get_limit_temperature_(TMP102LimitType limit) const; + void prepare_number_limits_(float *restored); + + TMP102LimitNumber *limit_controls_[2]{}; +#endif uint16_t config_{0}; uint16_t high_limit_{0}; @@ -54,4 +79,22 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #endif }; +#ifdef USE_TMP102_NUMBER +class TMP102LimitNumber final : public number::Number, public Parented { + public: + explicit TMP102LimitNumber(TMP102LimitType limit_type) : limit_type_(limit_type) {} + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + friend class TMP102Component; + float load_value_(float fallback); + bool save_value_(float value); + void control(float value) override; + + ESPPreferenceObject pref_; + TMP102LimitType limit_type_; + bool restore_value_{true}; +}; +#endif + } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6f2a499730c..fc6dd82ed98 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_TIME_TRIGGERS #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE +#define USE_TMP102_NUMBER #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/config/duplicate_numbers.yaml b/tests/component_tests/tmp102/config/duplicate_numbers.yaml new file mode 100644 index 00000000000..04f634741fd --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_numbers.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High One + - platform: tmp102 + tmp102_id: chip + temperature_low: + name: Low Two diff --git a/tests/component_tests/tmp102/config/numbers.yaml b/tests/component_tests/tmp102/config/numbers.yaml new file mode 100644 index 00000000000..272bd0dfe66 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers.yaml @@ -0,0 +1,24 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + temperature_low: + name: Low + restore_value: false diff --git a/tests/component_tests/tmp102/config/numbers_inherit.yaml b/tests/component_tests/tmp102/config/numbers_inherit.yaml new file mode 100644 index 00000000000..cbd4280f034 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_inherit.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: true + temperature_high: 140 + temperature_low: 120 + +number: + - platform: tmp102 + temperature_high: + name: High + temperature_low: + name: Low diff --git a/tests/component_tests/tmp102/config/numbers_initial_value.yaml b/tests/component_tests/tmp102/config/numbers_initial_value.yaml new file mode 100644 index 00000000000..60d25577014 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_initial_value.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + initial_value: 30 diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 49f2629baf8..b933db1d9bc 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,54 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_threshold_numbers_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(128, 20480, 19200)" in source + assert source.count("set_limit_control(") == 2 + assert "tmp102::TMP102_LIMIT_HIGH" in source + assert "tmp102::TMP102_LIMIT_LOW" in source + assert "set_restore_value(false)" in source + assert "set_max_value(127.9375f)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_NUMBER" in defines + assert "USE_TMP102_CONFIGURE" in defines + + +def test_threshold_numbers_use_sensor_thresholds( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers_inherit.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(144, 17920, 15360)" in source + assert "set_max_value(150.0f)" in source + + +def test_threshold_number_initial_value_is_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("numbers_initial_value.yaml") + assert read_config({}) is None + assert "[initial_value] is an invalid option" in capsys.readouterr().out + + +def test_duplicate_numbers_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_numbers.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 number may reference each temperature sensor" + in capsys.readouterr().out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ @@ -117,5 +165,12 @@ def test_precomputed_register_values() -> None: assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 +def test_threshold_encoding_rounds_half_steps_away_from_zero() -> None: + assert component.encode_temperature(30.03125, False) == 481 << 4 + assert component.encode_temperature(-30.03125, False) == (-481 << 4) & 0xFFFF + assert component.encode_temperature(30.03125, True) == 481 << 3 + assert component.encode_temperature(-30.03125, True) == (-481 << 3) & 0xFFFF + + def test_defaults_match_chip_reset() -> None: assert component.build_configuration({}) == 0x0080 diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index f86177c0f92..346efac83ec 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,10 +6,16 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor"): + for platform in ("sensor", "binary_sensor", "number"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") + cg.add_define("USE_TMP102_NUMBER") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] + manifest.dependencies = manifest.dependencies + [ + "i2c", + "sensor", + "binary_sensor", + "number", + ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 1e5f1f456ce..dfc2e2e2245 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -16,6 +16,16 @@ sensor: temperature_high: 140°C temperature_low: 120°C +number: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + temperature_high: + name: TMP102 High Threshold + restore_value: true + temperature_low: + name: TMP102 Low Threshold + restore_value: false + binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index cd06d8a83d7..6c294f4cdc6 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -3,12 +3,23 @@ #include #include +#include +#include +#include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" namespace esphome::tmp102::testing { +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + class RegisterBus final : public i2c::I2CBus { public: std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; @@ -48,21 +59,32 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; + TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; void SetUp() override { new (&App) Application; - char name[] = "tmp102-regression-tests"; - char friendly_name[] = "TMP102 tests"; - App.pre_setup(name, 23, friendly_name, 12); + App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); + host::setup_preferences(); + host::host_preferences->reset(); this->chip_.set_i2c_bus(&this->bus_); this->chip_.set_i2c_address(0x48); this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + EntityIdentity::configure(this->high_, "High", 0x102001); + EntityIdentity::configure(this->low_, "Low", 0x102002); + for (auto *number : {&this->high_, &this->low_}) { + number->set_parent(&this->chip_); + number->traits.set_min_value(-55); + number->traits.set_max_value(127.9375f); + number->traits.set_step(0.0625f); + } } void TearDown() override { App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); App.scheduler.call(millis()); + host::host_preferences->reset(); App.~Application(); } @@ -75,6 +97,31 @@ class TMP102Test : public ::testing::Test { void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { this->chip_.set_configuration(config, high, low); } + + static uint16_t encode(float temperature, bool extended = false) { + const int16_t steps = static_cast(std::lround(temperature / 0.0625f)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); + } + + void numbers_(float initial_high = 30, float initial_low = 25, bool extended = false) { + this->configure(extended ? 0x0090 : 0x0080, encode(initial_high, extended), encode(initial_low, extended)); + if (extended) { + this->high_.traits.set_max_value(150); + this->low_.traits.set_max_value(150); + } + this->chip_.set_limit_control(&this->high_); + this->chip_.set_limit_control(&this->low_); + } + + static float saved(TMP102LimitNumber &number) { + float value = NAN; + EXPECT_TRUE(number.make_entity_preference().load(&value)); + return value; + } + + static void save(TMP102LimitNumber &number, float value) { + ASSERT_TRUE(number.make_entity_preference().save(&value)); + } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -190,6 +237,62 @@ TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { EXPECT_FALSE(this->chip_.status_has_warning()); } +TEST_F(TMP102Test, QuantizedRuntimeValuesMatchRegistersAndPreferences) { + this->numbers_((10.0f - 32) * 5 / 9, (9.0f - 32) * 5 / 9); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, -12.25f); + EXPECT_FLOAT_EQ(this->low_.state, -12.75f); + EXPECT_EQ(this->bus_.registers[3], 0xF3C0); + for (auto [requested, expected] : + {std::pair{12.22f, 12.25f}, {-12.22f, -12.25f}, {12.03125f, 12.0625f}, {-12.03125f, -12.0625f}}) { + this->high_.make_call().set_value(requested).perform(); + EXPECT_FLOAT_EQ(this->high_.state, expected); + EXPECT_FLOAT_EQ(saved(this->high_), expected); + EXPECT_FLOAT_EQ(static_cast(this->bus_.registers[3]) / 256.0f, expected); + } +} + +TEST_F(TMP102Test, ExtendedModeRuntimeValueMatchesRegister) { + this->numbers_(140, 120, true); + this->chip_.setup(); + this->high_.make_call().set_value(145.0625f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 145.0625f); + EXPECT_EQ(this->bus_.registers[3], encode(145.0625f, true)); + EXPECT_FLOAT_EQ(saved(this->high_), 145.0625f); +} + +TEST_F(TMP102Test, HighOnlyInvalidRestoreFallsBackAgainstStaticLow) { + this->configure(0x0080, encode(30), encode(25)); + this->chip_.set_limit_control(&this->high_); + save(this->high_, 20); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_EQ(this->bus_.registers[3], encode(30)); + EXPECT_EQ(this->bus_.registers[2], encode(25)); + EXPECT_FLOAT_EQ(saved(this->high_), 30); +} + +TEST_F(TMP102Test, OrderingUsesAcceptedQuantizedThreshold) { + this->numbers_(30.02f, 30.01f); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 30); + this->high_.make_call().set_value(29.96f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(29.99f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); +} + +TEST_F(TMP102Test, FailedThresholdWriteWarningClearsAfterSuccessfulRead) { + this->numbers_(); + this->chip_.setup(); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(90).perform(); + EXPECT_TRUE(this->chip_.status_has_warning()); + this->chip_.update(); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -304,6 +407,87 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } } +TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); +} + +TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->numbers_(); + save(this->high_, NAN); + save(this->low_, INFINITY); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); +} + +TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->bus_.fail_write = 3; + this->chip_.setup(); + EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_FLOAT_EQ(saved(this->low_), 85); +} + +TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->numbers_(); + save(this->high_, 90); + this->bus_.fail_write = 3; + this->chip_.setup(); + ASSERT_TRUE(this->chip_.is_failed()); + const unsigned writes = this->bus_.writes; + this->high_.make_call().set_value(60).perform(); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { + this->numbers_(); + save(this->high_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->bus_.registers[3], encode(35)); + float value; + EXPECT_FALSE(this->low_.make_entity_preference().load(&value)); +} + +TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->chip_.setup(); + unsigned publications = 0; + this->high_.add_on_state_callback([&publications](float) { ++publications; }); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(100).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 90); + EXPECT_EQ(publications, 1); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { + this->numbers_(); + save(this->high_, 90); + this->high_.set_restore_value(false); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(40).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 40); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + } // namespace esphome::tmp102::testing #endif From 8acf885241946a80673d3687f09f9a80d9f6e692 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Tue, 13 Oct 2026 04:43:51 +0200 Subject: [PATCH 26/66] [bq76972] Add BQ76972 BMS chip monitoring component (#17720) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq76972/__init__.py | 111 +++++++ esphome/components/bq76972/bq76972.cpp | 298 ++++++++++++++++++ esphome/components/bq76972/bq76972.h | 51 +++ tests/components/bq76972/common.yaml | 59 ++++ tests/components/bq76972/test.esp32-idf.yaml | 3 + .../components/bq76972/test.esp8266-ard.yaml | 3 + 7 files changed, 526 insertions(+) create mode 100644 esphome/components/bq76972/__init__.py create mode 100644 esphome/components/bq76972/bq76972.cpp create mode 100644 esphome/components/bq76972/bq76972.h create mode 100644 tests/components/bq76972/common.yaml create mode 100644 tests/components/bq76972/test.esp32-idf.yaml create mode 100644 tests/components/bq76972/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 075b8113d88..f068a3a2215 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon +esphome/components/bq76972/* @limpkin esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py new file mode 100644 index 00000000000..5ccf043c250 --- /dev/null +++ b/esphome/components/bq76972/__init__.py @@ -0,0 +1,111 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@limpkin"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq76972" +MULTI_CONF = True + +bq76972_ns = cg.esphome_ns.namespace("bq76972") +BQ76972Component = bq76972_ns.class_( + "BQ76972Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REG_DISABLED = "reg_disabled" +CONF_CRC_ENABLED = "crc_enabled" +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ76972Component), + cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } + ) + .extend(i2c.i2c_device_schema(0x08)) + .extend(cv.polling_component_schema("10s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) + cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_cell_sensor(sens, idx)) + + +BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(BQ76972Component), + cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address), + }, + key=CONF_ADDRESS, +) + +automation.register_apply_action( + "bq76972.program_i2c_address", + BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA, + automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8), +) diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp new file mode 100644 index 00000000000..ea229307d5c --- /dev/null +++ b/esphome/components/bq76972/bq76972.cpp @@ -0,0 +1,298 @@ +#include "bq76972.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bq76972 { + +ESPHOME_LOG_TAG(TAG, "bq76972"); + +static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte +static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40; +static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length +static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12; +static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14; +static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34; +static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68; +static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090; +static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092; +static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236; +static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237; +static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A; +static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC; +// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N +static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9; +static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default +static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07; + +// 16 words with a CRC byte after every data byte +static constexpr size_t BQ76972_MAX_TRANSFER = 64; +static constexpr uint8_t BQ76972_MAX_POLLS = 50; + +static float raw_to_celsius(uint16_t raw) { return static_cast(raw) * 0.1f - 273.15f; } + +bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->read_bytes(start_register, data, len); + + // With CRC enabled, every data byte is followed by a CRC byte + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + if (!this->read_bytes(start_register, buffer.data(), len * 2)) + return false; + + // The first CRC also covers the write address, register and read address + const uint8_t crc0_input[4] = {static_cast(this->address_ << 1), start_register, + static_cast((this->address_ << 1) | 1), buffer[0]}; + if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register); + return false; + } + data[0] = buffer[0]; + for (size_t i = 1; i < len; ++i) { + if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast(i)); + return false; + } + data[i] = buffer[i * 2]; + } + return true; +} + +bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->write_bytes(start_register, data, len); + + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + + // The first CRC also covers the write address and register + const uint8_t crc0_input[3] = {static_cast(this->address_ << 1), start_register, data[0]}; + buffer[0] = data[0]; + buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true); + for (size_t i = 1; i < len; ++i) { + buffer[i * 2] = data[i]; + buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true); + } + return this->write_bytes(start_register, buffer.data(), len * 2); +} + +bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) { + std::array bytes; + if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2)) + return false; + for (size_t i = 0; i < num_words; i++) + data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]); + return true; +} + +bool BQ76972Component::send_subcommand_(uint16_t subcommand) { + const uint8_t bytes[2] = {static_cast(subcommand & 0xFF), static_cast(subcommand >> 8)}; + return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2); +} + +bool BQ76972Component::wait_for_subcommand_() { + uint8_t bytes[2]; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2)) + return false; + if (bytes[0] != 0xFF && bytes[1] != 0xFF) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::wait_for_cfgupdate_() { + uint16_t status; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1)) + return false; + if (status & 0x0001) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) { + return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() && + this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len); +} + +bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) { + if (!this->send_subcommand_(subcommand)) + return false; + if (len == 0) + return true; + if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len)) + 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 tail[2] = {static_cast(~sum), static_cast(4 + len)}; + return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2); +} + +bool BQ76972Component::enter_cfg_update_() { + if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0)) + return false; + delay(10); + return this->wait_for_cfgupdate_(); +} + +bool BQ76972Component::exit_cfg_update_() { + return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0); +} + +bool BQ76972Component::regs_disabled_(bool &out) { + uint8_t reg12; + uint8_t reg0; + if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, ®12, 1) || + !this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, ®0, 1)) + return false; + out = reg12 == 0 && (reg0 & 0x01) == 0; + return true; +} + +const LogString *BQ76972Component::configure_() { + uint16_t internal_temp; + if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1)) + return LOG_STR(ESP_LOG_MSG_COMM_FAIL); + + // Only write the regulator config when it differs, see + // https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout + bool regs_disabled = true; + if (this->reg_disable_ && !this->regs_disabled_(regs_disabled)) + return LOG_STR("Reading regulator config failed"); + const bool write_regs = !regs_disabled; + + bool write_thermistors = false; + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) + write_thermistors = true; + } + + if (write_regs || write_thermistors) { + if (!this->enter_cfg_update_()) + return LOG_STR("Entering config update mode failed"); + const LogString *error = this->write_config_(write_regs); + // The chip stops measuring in config update mode, so leave it even after a failed write + if (!this->exit_cfg_update_() && error == nullptr) + error = LOG_STR("Exiting config update mode failed"); + if (error != nullptr) + return error; + if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled)) + return LOG_STR("Disabling regulators failed"); + } + + ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp)); + return nullptr; +} + +const LogString *BQ76972Component::write_config_(bool write_regs) { + if (write_regs) { + const uint8_t zero = 0; + if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) || + !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) + return LOG_STR("Writing regulator config failed"); + } + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) + continue; + if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) + return LOG_STR("Writing thermistor pin config failed"); + } + return nullptr; +} + +void BQ76972Component::setup() { + if (const LogString *error = this->configure_(); error != nullptr) + this->mark_failed(error); +} + +void BQ76972Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ76972:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " CRC Mode: %s\n" + " Regulators disabled: %s", + this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_)); +} + +void BQ76972Component::program_i2c_address(uint8_t address) { + if (this->is_failed()) + return; + const uint8_t shifted = address << 1; + if (!this->enter_cfg_update_()) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + // The new address only takes effect with the swap, so after a failed write the chip still answers at the + // old one and must still be taken out of config update mode + const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) && + this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0); + if (written) { + delay(10); + this->set_i2c_address(address); + } + if (!this->exit_cfg_update_() || !written) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + ESP_LOGI(TAG, "I2C address set to 0x%02X", address); +} + +template static bool any_set(const std::array &sensors) { + for (auto *sensor : sensors) { + if (sensor != nullptr) + return true; + } + return false; +} + +void BQ76972Component::update() { + // Each group publishes as soon as its read succeeds, so one failed read does not hold back the others + bool ok = true; + uint16_t words[BQ76972_NUM_CELLS]; + if (this->stack_voltage_sensor_ != nullptr) { + if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) { + if (words[0] != 0) + this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units + } else { + ok = false; + } + } + if (any_set(this->cell_sensors_)) { + if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) { + for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) { + if (this->cell_sensors_[i] != nullptr && words[i] != 0) + this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV + } + } else { + ok = false; + } + } + if (any_set(this->temperature_sensors_)) { + if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) { + for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) { + if (this->temperature_sensors_[i] != nullptr) + this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i])); + } + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +} // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h new file mode 100644 index 00000000000..d27a4ce96f4 --- /dev/null +++ b/esphome/components/bq76972/bq76972.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq76972 { + +static constexpr size_t BQ76972_NUM_CELLS = 16; +static constexpr size_t BQ76972_NUM_TEMPERATURES = 10; + +class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } + void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } + 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; } + + void program_i2c_address(uint8_t address); + + protected: + bool read_block_(uint8_t start_register, uint8_t *data, size_t len); + bool write_block_(uint8_t start_register, const uint8_t *data, size_t len); + bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words); + bool send_subcommand_(uint16_t subcommand); + bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len); + bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len); + bool wait_for_subcommand_(); + bool wait_for_cfgupdate_(); + bool enter_cfg_update_(); + bool exit_cfg_update_(); + bool regs_disabled_(bool &out); + const LogString *configure_(); + const LogString *write_config_(bool write_regs); + + bool reg_disable_{false}; + bool crc_mode_{false}; + + sensor::Sensor *stack_voltage_sensor_{nullptr}; + std::array cell_sensors_{}; + std::array temperature_sensors_{}; +}; + +} // namespace esphome::bq76972 diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml new file mode 100644 index 00000000000..42a9f633aff --- /dev/null +++ b/tests/components/bq76972/common.yaml @@ -0,0 +1,59 @@ +bq76972: + id: bq76972_id + address: 0x08 + crc_enabled: true + reg_disabled: true + update_interval: 10s + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" + +button: + - platform: template + name: "Program BQ I2C Address" + on_press: + - bq76972.program_i2c_address: + id: bq76972_id + address: 0x09 + - bq76972.program_i2c_address: !lambda "return 0x08;" diff --git a/tests/components/bq76972/test.esp32-idf.yaml b/tests/components/bq76972/test.esp32-idf.yaml new file mode 100644 index 00000000000..b0842c47a5c --- /dev/null +++ b/tests/components/bq76972/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq76972: !include common.yaml diff --git a/tests/components/bq76972/test.esp8266-ard.yaml b/tests/components/bq76972/test.esp8266-ard.yaml new file mode 100644 index 00000000000..504777ab5ba --- /dev/null +++ b/tests/components/bq76972/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq76972: !include common.yaml From 501716580043034bbf8fbd81507ecc4fbbc1cacf Mon Sep 17 00:00:00 2001 From: Sourabh Jaiswal Date: Tue, 13 Oct 2026 08:28:25 +0530 Subject: [PATCH 27/66] [selec_meter] Add EM4M (3-phase) support via model option (#17782) Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/selec_meter/selec_meter.cpp | 399 ++++++++++++++++-- esphome/components/selec_meter/selec_meter.h | 135 ++++++ .../selec_meter/selec_meter_registers.h | 133 +++++- esphome/components/selec_meter/sensor.py | 262 +++++++++++- esphome/core/defines.h | 1 + tests/components/selec_meter/common-em4m.yaml | 129 ++++++ .../selec_meter/test-em4m.esp32-idf.yaml | 8 + .../selec_meter/test-em4m.esp8266-ard.yaml | 8 + .../selec_meter/test.esp32-idf.yaml | 3 +- .../selec_meter/test.esp8266-ard.yaml | 3 +- .../selec_meter/test.rp2040-ard.yaml | 3 +- .../components/selec_meter/__init__.py | 0 .../components/selec_meter/test_sensor.py | 103 +++++ 13 files changed, 1105 insertions(+), 82 deletions(-) create mode 100644 tests/components/selec_meter/common-em4m.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp32-idf.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/selec_meter/__init__.py create mode 100644 tests/unit_tests/components/selec_meter/test_sensor.py diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index a11cc6a008a..9a895b0e0e9 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,54 +1,358 @@ #include "selec_meter.h" #include "selec_meter_registers.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" +#include +#include +#include + namespace esphome::selec_meter { namespace helpers = modbus::helpers; ESPHOME_LOG_TAG(TAG, "selec_meter"); -static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers +static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers + +// Durable refusals an optional read tolerates before it is given up on +static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5; + +static const LogString *read_state_name(ReadState state) { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return LOG_STR("main block"); + case ReadState::READ_STATE_SERIAL_NUMBER: + return LOG_STR("serial number"); + case ReadState::READ_STATE_DG_SENSING: + return LOG_STR("DG sensing"); + case ReadState::READ_STATE_IDLE: + default: + return LOG_STR("idle"); + } +} + +// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when +// `reg` is not wholly inside `registers` +static float read_float(std::span registers, uint16_t start_address, uint16_t reg, bool word_swapped) { + auto value = word_swapped ? helpers::value_at(registers, start_address, reg) + : helpers::value_at(registers, start_address, reg); + return value.value_or(NAN); +} + +// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded +// phase's power factor; a non-finite field is skipped on its own so the others still publish. +static void publish_float(sensor::Sensor *sens, std::span registers, uint16_t reg, bool kilo, + bool word_swapped) { + float value = read_float(registers, 0, reg, word_swapped); + if (!std::isfinite(value)) { + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + return; + } + sens->publish_state(kilo ? value * 1000.0f : value); +} + +#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR)) +// Counts one failure of an optional read; callers only run while `failures` is below the limit +static void note_optional_read_failure(uint8_t &failures, const LogString *name) { + if (++failures == MAX_OPTIONAL_READ_FAILURES) { + ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures); + } +} +#endif + +SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) + return {0, EM4M_REGISTER_COUNT}; +#endif + return {0, EM2M_REGISTER_COUNT}; + case ReadState::READ_STATE_SERIAL_NUMBER: + return {EM4M_SERIAL_NUMBER, 2}; + case ReadState::READ_STATE_DG_SENSING: + return {EM4M_DG_SENSING, 2}; + case ReadState::READ_STATE_IDLE: + default: + return {0, 0}; + } +} + +bool SelecMeter::wanted_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return true; +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif + default: + return false; + } +} + +ReadState SelecMeter::next_read_state_after_(ReadState current) const { + for (auto next = static_cast(current) + 1; next < static_cast(ReadState::READ_STATE_IDLE); next++) { + if (this->wanted_(static_cast(next))) + return static_cast(next); + } + return ReadState::READ_STATE_IDLE; +} void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) { - if (!modbus::succeeded(status)) - return; // the hub already logs exception responses - - // Publish a sensor if both of its registers are in this response; skipping absent registers keeps - // this correct for any read range, so the poll may be split into multiple requests. - // Values are 32-bit floats, low word first. - auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { - if (sensor == nullptr) - return; - if (auto value = helpers::value_at(registers, start_address, reg)) - sensor->publish_state(*value * unit); + ReadState current = this->read_state_; + if (current == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected response while idle, dropping"); + return; + } + // start_address comes from the exact request this answers, so a stale reply cannot match another read + if (start_address != this->read_range_(current).start) { + this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false); + return; + } + if (status.has_value()) { + const auto code = *status; + ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast(code), + LOG_STR_ARG(read_state_name(current))); + // Illegal function/address are durable; busy, acknowledge and the rest are transient + const bool durable = + code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + this->fail_current_read_(nullptr, durable); + return; + } + switch (current) { + case ReadState::READ_STATE_MAIN_BLOCK: { +#ifdef USE_SELEC_METER_EM4M + bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers); +#else + bool ok = this->decode_em2m_(registers); +#endif + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(); + } + break; + } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + this->decode_serial_number_(registers); + break; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + this->decode_dg_sensing_(registers); + break; +#endif + default: + break; + } + // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally. + this->start_read_(this->next_read_state_after_(current)); +} + +void SelecMeter::fail_current_read_(const LogString *reason, bool durable) { + if (this->read_state_ == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected failure while idle, dropping"); + return; + } + ReadState failed_state = this->read_state_; + if (reason != nullptr) { + ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state))); + } + // Only the main block drives component health; an unsupported optional read is not a fault + if (failed_state == ReadState::READ_STATE_MAIN_BLOCK) + this->status_set_warning(); +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable) + note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state)); +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + if (failed_state == ReadState::READ_STATE_DG_SENSING && durable) + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state)); +#endif + this->start_read_(this->next_read_state_after_(failed_state)); +} + +bool SelecMeter::on_no_response(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("No Modbus response")); + return false; +} + +void SelecMeter::on_not_sent(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("Modbus request not sent")); +} + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) +void SelecMeter::decode_serial_number_(std::span registers) { + auto serial = + this->word_swap_ + ? helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER) + : helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER); + if (!serial) { + ESP_LOGW(TAG, "Unexpected response size for serial number"); + note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER)); + return; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08" PRIX32, *serial); + this->serial_number_sensor_->publish_state(buf); + // The serial number never changes, so stop reading it + this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES; +} +#endif + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) +void SelecMeter::decode_dg_sensing_(std::span registers) { + float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_); + if (!std::isfinite(value)) { + ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring"); + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING)); + return; + } + this->dg_sensing_sensor_->publish_state(value != 0); + this->dg_sensing_failures_ = 0; +} +#endif + +bool SelecMeter::publish_fields_(std::span registers, std::span fields, + uint8_t min_count) { + if (registers.size() < min_count) { + ESP_LOGW(TAG, "Short response: %u registers", static_cast(registers.size())); + return false; + } + for (const auto &entry : fields) { + FloatField field; + progmem_memcpy(&field, &entry, sizeof(field)); + sensor::Sensor *sens = this->*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg, field.kilo, this->word_swap_); + } + return true; +} + +bool SelecMeter::decode_em2m_(std::span registers) { + static constexpr FloatField EM2M_FIELDS[] PROGMEM = { + {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false}, + {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false}, + {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true}, + {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false}, + {&SelecMeter::current_sensor_, SELEC_CURRENT, false}, + {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true}, }; + return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT); +} + +#ifdef USE_SELEC_METER_EM4M +bool SelecMeter::decode_em4m_(std::span registers) { + static constexpr FloatField EM4M_FIELDS[] PROGMEM = { + // Common quantities, shared sensor keys with EM2M + {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false}, + {&SelecMeter::current_sensor_, EM4M_CURRENT, false}, + {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true}, + {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true}, + {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false}, + // Line-to-line and net (mains/DG) quantities have no per-phase equivalent + {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false}, + {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false}, + {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false}, + {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false}, + {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false}, + {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false}, + }; + + // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart + struct PhaseField { + sensor::Sensor *Phase::*sensor; + uint16_t reg_l1; + bool kilo; + }; + static constexpr PhaseField PHASE_FIELDS[] PROGMEM = { + {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false}, + {&Phase::current_sensor_, EM4M_CURRENT_L1, false}, + {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true}, + {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true}, + {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true}, + {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false}, + {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false}, + }; + + if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT)) + return false; + for (const auto &entry : PHASE_FIELDS) { + PhaseField field; + progmem_memcpy(&field, &entry, sizeof(field)); + for (uint8_t phase = 0; phase < 3; phase++) { + sensor::Sensor *sens = this->phases_[phase].*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_); + } + } + return true; +} +#endif + +void SelecMeter::update() { + if (this->read_state_ != ReadState::READ_STATE_IDLE) { + ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress", + LOG_STR_ARG(read_state_name(this->read_state_))); + return; + } + this->start_read_(ReadState::READ_STATE_MAIN_BLOCK); +} - publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT); - publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT); - publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT); - publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT); - publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT); - publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); +void SelecMeter::start_read_(ReadState state) { + this->read_state_ = state; + if (state == ReadState::READ_STATE_IDLE) + return; + const ReadRange range = this->read_range_(state); + // A refused send gets no callback, so unwind here like the async failure paths + if (!this->read_input_registers(range.start, range.count)) + this->fail_current_read_(LOG_STR("Modbus request refused")); } -void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" + " Model: %s\n" + " Byte Order: %s\n" " Address: 0x%02X", + this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"), + this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"), this->address_); LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_); @@ -67,6 +371,41 @@ void SelecMeter::dump_config() { LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_); +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) { + LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_); + LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_); + LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_); + LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_); + for (uint8_t phase = 0; phase < 3; phase++) { + const auto &p = this->phases_[phase]; + ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase); + LOG_SENSOR(" ", "Voltage", p.voltage_sensor_); + LOG_SENSOR(" ", "Current", p.current_sensor_); + LOG_SENSOR(" ", "Active Power", p.active_power_sensor_); + LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_); + LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_); + LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_); + LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_); + LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_); + LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_); + LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_); + } + LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_); + } +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_); +#endif +#endif } } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 470242c9184..98fea2987e9 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#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 #include @@ -15,6 +22,24 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } +// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C) +#define SELEC_METER_PHASE_SENSOR(name) \ + void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; } + +enum class Model : uint8_t { + MODEL_EM2M, + MODEL_EM4M, +}; + +// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside +// the main block and get their own transaction. +enum class ReadState : uint8_t { + READ_STATE_MAIN_BLOCK, + READ_STATE_SERIAL_NUMBER, + READ_STATE_DG_SENSING, + READ_STATE_IDLE, +}; + class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) @@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev SELEC_METER_SENSOR(maximum_demand_reactive_power) SELEC_METER_SENSOR(maximum_demand_apparent_power) +#ifdef USE_SELEC_METER_EM4M + // EM4M per-phase sensors, indexed by phase (see Phase below) + SELEC_METER_PHASE_SENSOR(voltage) + SELEC_METER_PHASE_SENSOR(current) + SELEC_METER_PHASE_SENSOR(active_power) + SELEC_METER_PHASE_SENSOR(reactive_power) + SELEC_METER_PHASE_SENSOR(apparent_power) + SELEC_METER_PHASE_SENSOR(power_factor) + SELEC_METER_PHASE_SENSOR(import_active_energy) + SELEC_METER_PHASE_SENSOR(export_active_energy) + SELEC_METER_PHASE_SENSOR(import_reactive_energy) + SELEC_METER_PHASE_SENSOR(export_reactive_energy) + SELEC_METER_PHASE_SENSOR(apparent_energy) + + // Line-to-line and aggregate sensors, not tied to one phase + SELEC_METER_SENSOR(voltage_l12) + SELEC_METER_SENSOR(voltage_l23) + SELEC_METER_SENSOR(voltage_l31) + SELEC_METER_SENSOR(average_voltage_ll) + SELEC_METER_SENSOR(net_active_energy_mains) + SELEC_METER_SENSOR(net_reactive_energy_mains) + SELEC_METER_SENSOR(net_apparent_energy_mains) + SELEC_METER_SENSOR(net_active_energy_dg) + SELEC_METER_SENSOR(net_reactive_energy_dg) + SELEC_METER_SENSOR(net_apparent_energy_dg) +#endif + + void set_model(Model model) { this->model_ = model; } + // true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order) + // false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order) + void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; } +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; } +#endif + void update() override; void on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override; + bool on_no_response(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; void dump_config() override; + + protected: + // One float field of the main block: target sensor, register and whether it is published x1000 + struct FloatField { + sensor::Sensor *SelecMeter::*sensor; + uint16_t reg; + bool kilo; + }; + + // Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than + // `min_count`, and the caller sets or clears the warning from the result + bool publish_fields_(std::span registers, std::span fields, uint8_t min_count); + bool decode_em2m_(std::span registers); +#ifdef USE_SELEC_METER_EM4M + bool decode_em4m_(std::span registers); +#ifdef USE_TEXT_SENSOR + void decode_serial_number_(std::span registers); +#endif +#ifdef USE_BINARY_SENSOR + void decode_dg_sensing_(std::span registers); +#endif +#endif + // First register and register count of the read for `state` + struct ReadRange { + uint16_t start; + uint8_t count; + }; + ReadRange read_range_(ReadState state) const; + // Whether the read cycle should run `state` on this pass + bool wanted_(ReadState state) const; + // Next wanted step after `current`, so one failed range does not cancel the other reads + ReadState next_read_state_after_(ReadState current) const; + // Sends the read for `state`; safe from a callback per the modbus callback contract + void start_read_(ReadState state); + // Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and + // only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged + void fail_current_read_(const LogString *reason, bool durable = false); + +#ifdef USE_SELEC_METER_EM4M + // phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart + struct Phase { + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *active_power_sensor_{nullptr}; + sensor::Sensor *reactive_power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + sensor::Sensor *import_active_energy_sensor_{nullptr}; + sensor::Sensor *export_active_energy_sensor_{nullptr}; + sensor::Sensor *import_reactive_energy_sensor_{nullptr}; + sensor::Sensor *export_reactive_energy_sensor_{nullptr}; + sensor::Sensor *apparent_energy_sensor_{nullptr}; + }; + Phase phases_[3]; +#endif + + Model model_{Model::MODEL_EM2M}; + bool word_swap_{false}; + ReadState read_state_{ReadState::READ_STATE_IDLE}; + + // An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + text_sensor::TextSensor *serial_number_sensor_{nullptr}; + // Also set to the limit once the serial number is published, since it never changes + uint8_t serial_number_failures_{0}; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr}; + uint8_t dg_sensing_failures_{0}; +#endif }; } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index d299560aaba..e7678ccabee 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,30 +1,117 @@ #pragma once +#include + namespace esphome::selec_meter { -static const float TWO_DEC_UNIT = 0.01; -static const float ONE_DEC_UNIT = 0.1; -static const float NO_DEC_UNIT = 1; -static const float MULTIPLY_TEN_UNIT = 10; -static const float MULTIPLY_THOUSAND_UNIT = 1000; - /* PHASE STATUS REGISTERS */ -static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; -static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; -static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; -static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; -static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; -static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; -static const uint16_t SELEC_APPARENT_ENERGY = 0x000C; -static const uint16_t SELEC_ACTIVE_POWER = 0x000E; -static const uint16_t SELEC_REACTIVE_POWER = 0x0010; -static const uint16_t SELEC_APPARENT_POWER = 0x0012; -static const uint16_t SELEC_VOLTAGE = 0x0014; -static const uint16_t SELEC_CURRENT = 0x0016; -static const uint16_t SELEC_POWER_FACTOR = 0x0018; -static const uint16_t SELEC_FREQUENCY = 0x001A; -static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; -static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; -static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; +static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; +static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; +static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; +static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; +static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; +static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; +static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C; +static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E; +static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010; +static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012; +static constexpr uint16_t SELEC_VOLTAGE = 0x0014; +static constexpr uint16_t SELEC_CURRENT = 0x0016; +static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018; +static constexpr uint16_t SELEC_FREQUENCY = 0x001A; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; + +/* EM4M REGISTERS -- offsets are register indices from input register 30000 */ +static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers + +/* EM4M -- common quantities shared with the EM2M sensor keys */ +static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN +static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current +static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW +static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr +static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA +static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF +static constexpr uint16_t EM4M_FREQUENCY = 0x0038; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export +static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL + +// Net (import - export) energy totals, split by source. Only meaningful when the +// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs. +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS) +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG) + +/* EM4M -- per-phase quantities, no EM2M equivalent */ +static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N +static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N +static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N +static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12 +static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23 +static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31 +static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010; +static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012; +static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014; +static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1 +static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2 +static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3 +static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1 +static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2 +static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3 +static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1 +static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2 +static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3 +static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030; +static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032; +static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export +static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3 + +// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above +// document the datasheet and are checked against that stride here. +static constexpr uint16_t EM4M_PHASE_STRIDE = 2; +#define SELEC_EM4M_CHECK_STRIDE(name) \ + static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \ + EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \ + #name " per-phase registers must be EM4M_PHASE_STRIDE apart"); +SELEC_EM4M_CHECK_STRIDE(VOLTAGE) +SELEC_EM4M_CHECK_STRIDE(CURRENT) +SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER) +SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR) +SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY) +#undef SELEC_EM4M_CHECK_STRIDE + +// Far outside the main 0-113 register block; read via a separate Modbus transaction. +static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684 +static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710 } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 04aef00d46b..5e6709ec97d 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg -from esphome.components import modbus, sensor +from esphome.components import binary_sensor, modbus, sensor, text_sensor +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_SERIAL_NUMBER, +) import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -11,6 +17,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_MODEL, + CONF_PHASE_A, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, @@ -24,6 +34,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, @@ -37,9 +48,22 @@ from esphome.const import ( ) from esphome.types import ConfigType -AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] +CONF_DG_SENSING = "dg_sensing" + + +def AUTO_LOAD(config: ConfigType) -> list[str]: + return ["modbus"] + [ + component + for key, component in ( + (CONF_SERIAL_NUMBER, "text_sensor"), + (CONF_DG_SENSING, "binary_sensor"), + ) + if not config or key in config + ] + + CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" CONF_APPARENT_ENERGY = "apparent_energy" @@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" +CONF_VOLTAGE_L12 = "voltage_l12" +CONF_VOLTAGE_L23 = "voltage_l23" +CONF_VOLTAGE_L31 = "voltage_l31" + +CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll" +CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains" +CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains" +CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains" +CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg" +CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg" +CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) +SelecMeterModel = selec_meter_ns.enum("Model", is_class=True) -SENSORS = { +MODEL_EM2M = "em2m" +MODEL_EM4M = "em4m" +MODELS = { + MODEL_EM2M: SelecMeterModel.MODEL_EM2M, + MODEL_EM4M: SelecMeterModel.MODEL_EM4M, +} + +# Register word order of the FP32 values: big_endian sends the high word first (ABCD), +# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own +# register 40070 (Endianness Selection) menu names. +BYTE_ORDER_MSRF = "msrf" +BYTE_ORDER_LSRF = "lsrf" +# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side. +BYTE_ORDER_WORD_SWAP = { + BYTE_ORDER_BIG: False, + BYTE_ORDER_LITTLE: True, + BYTE_ORDER_MSRF: False, + BYTE_ORDER_LSRF: True, +} +validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True) + +# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M. +EM2M_SENSORS = { CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_APPARENT_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + +# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate +# (Total/Average) registers rather than a single-phase measurement. +COMMON_SENSORS = { CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, @@ -72,11 +145,6 @@ SENSORS = { device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=2, @@ -87,11 +155,6 @@ SENSORS = { accuracy_decimals=2, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_APPARENT_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=3, @@ -154,13 +217,115 @@ SENSORS = { ), } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}) - .extend( - {cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()} + +def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]: + """One identical sensor_schema() per key.""" + return {key: sensor.sensor_schema(**schema_kwargs) for key in keys} + + +# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like +# sdm_meter/atm90e32 do for 3-phase meters. +PHASE_SENSORS = { + key: {**COMMON_SENSORS, **EM2M_SENSORS}[key] + for key in ( + CONF_VOLTAGE, + CONF_CURRENT, + CONF_ACTIVE_POWER, + CONF_REACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_IMPORT_ACTIVE_ENERGY, + CONF_EXPORT_ACTIVE_ENERGY, + CONF_IMPORT_REACTIVE_ENERGY, + CONF_EXPORT_REACTIVE_ENERGY, + CONF_APPARENT_ENERGY, ) - .extend(cv.polling_component_schema("10s")) - .extend(modbus.modbus_device_schema(0x01)) +} + +PHASE_SCHEMA = cv.Schema( + {cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()} +) + +# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they +# stay flat rather than living under phase_a/phase_b/phase_c. +EM4M_SENSORS = { + **_sensor_group( + CONF_VOLTAGE_L12, + CONF_VOLTAGE_L23, + CONF_VOLTAGE_L31, + CONF_AVERAGE_VOLTAGE_LL, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + # Net (import - export) energy totals, split by source. Only meaningful when the meter's + # "Dual Source" setting is Yes; DG registers read 0 on single-source installs. + **_sensor_group( + CONF_NET_ACTIVE_ENERGY_MAINS, + CONF_NET_ACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_REACTIVE_ENERGY_MAINS, + CONF_NET_REACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_APPARENT_ENERGY_MAINS, + CONF_NET_APPARENT_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), +} + +PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + + +def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]: + return {cv.Optional(key): schema for key, schema in sensors.items()} + + +_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend( + cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01) +) + +# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory +# default (MSRF / Big Endian), confirmed against real hardware. +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_EM2M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM2M_SENSORS), + } + ), + MODEL_EM4M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_BIG + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM4M_SENSORS), + **{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES}, + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + default_type=MODEL_EM2M, + enum=MODELS, + lower=True, ) @@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for name in SENSORS: - if name in config: - sens = await sensor.new_sensor(config[name]) - cg.add(getattr(var, f"set_{name}_sensor")(sens)) + cg.add(var.set_model(config[CONF_MODEL])) + cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) + if config[CONF_MODEL] == MODEL_EM4M: + cg.add_define("USE_SELEC_METER_EM4M") + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) + await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors( + CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor + ) + await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor) + await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor) + await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor) + await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor) + await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor) + await sensors( + CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor + ) + await sensors( + CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor + ) + await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor) + await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor) + await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor) + for phase_index, phase in enumerate(PHASES): + if (phase_config := config.get(phase)) is None: + continue + for name in PHASE_SENSORS: + if name in phase_config: + sens = await sensor.new_sensor(phase_config[name]) + cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens)) + await text_sensor.sub_text_sensors(config)( + CONF_SERIAL_NUMBER, var.set_serial_number_sensor + ) + await binary_sensor.sub_binary_sensors(config)( + CONF_DG_SENSING, var.set_dg_sensing_sensor + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fc6dd82ed98..a7d2084a540 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -251,6 +251,7 @@ #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT +#define USE_SELEC_METER_EM4M #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/selec_meter/common-em4m.yaml b/tests/components/selec_meter/common-em4m.yaml new file mode 100644 index 00000000000..793e3c30a6c --- /dev/null +++ b/tests/components/selec_meter/common-em4m.yaml @@ -0,0 +1,129 @@ +sensor: + - platform: selec_meter + modbus_id: modbus_bus + model: em4m + byte_order: little_endian + phase_a: + voltage: + name: SelecEM4M Voltage L1 + current: + name: SelecEM4M Current L1 + active_power: + name: SelecEM4M Active Power L1 + reactive_power: + name: SelecEM4M Reactive Power L1 + apparent_power: + name: SelecEM4M Apparent Power L1 + power_factor: + name: SelecEM4M Power Factor L1 + import_active_energy: + name: SelecEM4M Import Active Energy L1 + export_active_energy: + name: SelecEM4M Export Active Energy L1 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L1 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L1 + apparent_energy: + name: SelecEM4M Apparent Energy L1 + phase_b: + voltage: + name: SelecEM4M Voltage L2 + current: + name: SelecEM4M Current L2 + active_power: + name: SelecEM4M Active Power L2 + reactive_power: + name: SelecEM4M Reactive Power L2 + apparent_power: + name: SelecEM4M Apparent Power L2 + power_factor: + name: SelecEM4M Power Factor L2 + import_active_energy: + name: SelecEM4M Import Active Energy L2 + export_active_energy: + name: SelecEM4M Export Active Energy L2 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L2 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L2 + apparent_energy: + name: SelecEM4M Apparent Energy L2 + phase_c: + voltage: + name: SelecEM4M Voltage L3 + current: + name: SelecEM4M Current L3 + active_power: + name: SelecEM4M Active Power L3 + reactive_power: + name: SelecEM4M Reactive Power L3 + apparent_power: + name: SelecEM4M Apparent Power L3 + power_factor: + name: SelecEM4M Power Factor L3 + import_active_energy: + name: SelecEM4M Import Active Energy L3 + export_active_energy: + name: SelecEM4M Export Active Energy L3 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L3 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L3 + apparent_energy: + name: SelecEM4M Apparent Energy L3 + voltage_l12: + name: SelecEM4M Voltage L12 + voltage_l23: + name: SelecEM4M Voltage L23 + voltage_l31: + name: SelecEM4M Voltage L31 + average_voltage_ll: + name: SelecEM4M Average Voltage LL + active_power: + name: SelecEM4M Active Power + reactive_power: + name: SelecEM4M Reactive Power + apparent_power: + name: SelecEM4M Apparent Power + voltage: + name: SelecEM4M Average Voltage + current: + name: SelecEM4M Average Current + power_factor: + name: SelecEM4M Average Power Factor + frequency: + name: SelecEM4M Frequency + import_active_energy: + name: SelecEM4M Total Import Active Energy + export_active_energy: + name: SelecEM4M Total Export Active Energy + import_reactive_energy: + name: SelecEM4M Total Import Reactive Energy + export_reactive_energy: + name: SelecEM4M Total Export Reactive Energy + net_active_energy_mains: + name: SelecEM4M Net Active Energy Mains + net_reactive_energy_mains: + name: SelecEM4M Net Reactive Energy Mains + net_apparent_energy_mains: + name: SelecEM4M Net Apparent Energy Mains + net_active_energy_dg: + name: SelecEM4M Net Active Energy DG + net_reactive_energy_dg: + name: SelecEM4M Net Reactive Energy DG + net_apparent_energy_dg: + name: SelecEM4M Net Apparent Energy DG + maximum_demand_active_power: + name: SelecEM4M Maximum Demand Active Power + disabled_by_default: true + maximum_demand_reactive_power: + name: SelecEM4M Maximum Demand Reactive Power + disabled_by_default: true + maximum_demand_apparent_power: + name: SelecEM4M Maximum Demand Apparent Power + disabled_by_default: true + serial_number: + name: SelecEM4M Serial Number + dg_sensing: + name: SelecEM4M DG Sensing diff --git a/tests/components/selec_meter/test-em4m.esp32-idf.yaml b/tests/components/selec_meter/test-em4m.esp32-idf.yaml new file mode 100644 index 00000000000..56c7c38bf93 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO26 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test-em4m.esp8266-ard.yaml b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml new file mode 100644 index 00000000000..7cdabad7d66 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + flow_control_pin: GPIO15 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test.esp32-idf.yaml b/tests/components/selec_meter/test.esp32-idf.yaml index 4df9f5863e1..3c588d0afcd 100644 --- a/tests/components/selec_meter/test.esp32-idf.yaml +++ b/tests/components/selec_meter/test.esp32-idf.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO26 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 6daa08c22bd..6f4125b47b4 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO15 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.rp2040-ard.yaml b/tests/components/selec_meter/test.rp2040-ard.yaml index a65500ccc38..687622481ea 100644 --- a/tests/components/selec_meter/test.rp2040-ard.yaml +++ b/tests/components/selec_meter/test.rp2040-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO6 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml - -<<: !include common.yaml diff --git a/tests/unit_tests/components/selec_meter/__init__.py b/tests/unit_tests/components/selec_meter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/selec_meter/test_sensor.py b/tests/unit_tests/components/selec_meter/test_sensor.py new file mode 100644 index 00000000000..b4b14cd7e1f --- /dev/null +++ b/tests/unit_tests/components/selec_meter/test_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the selec_meter configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.selec_meter import sensor as selec_meter +import esphome.config_validation as cv +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return selec_meter.CONFIG_SCHEMA(config) + + +def test_model_defaults_to_em2m() -> None: + config = _validate({"voltage": {"name": "Voltage"}}) + assert config["model"] == selec_meter.MODEL_EM2M + + +def test_model_is_case_insensitive() -> None: + config = _validate({"model": "EM4M"}) + assert config["model"] == selec_meter.MODEL_EM4M + + +def test_model_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"): + _validate({"model": "em9m"}) + + +@pytest.mark.parametrize( + ("model", "key", "value"), + [ + ("em2m", "voltage_l12", {"name": "Test"}), + ("em2m", "serial_number", {"name": "Test"}), + ("em2m", "dg_sensing", {"name": "Test"}), + ("em2m", "phase_a", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_b", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_c", {"voltage": {"name": "Voltage"}}), + ("em4m", "total_active_energy", {"name": "Test"}), + ("em4m", "total_reactive_energy", {"name": "Test"}), + ("em4m", "apparent_energy", {"name": "Test"}), + ], +) +def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed") as err: + _validate({"model": model, key: value}) + assert err.value.path == [key] + + +def test_em4m_only_key_rejected_on_default_model() -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed"): + _validate({"voltage_l12": {"name": "Test"}}) + + +def test_em4m_with_phases_accepted() -> None: + config = _validate( + { + "model": "em4m", + "phase_a": {"voltage": {"name": "Voltage L1"}}, + "phase_c": {"power_factor": {"name": "PF L3"}}, + } + ) + assert "voltage" in config["phase_a"] + assert "power_factor" in config["phase_c"] + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "little_endian", True), + ("em4m", "big_endian", False), + ], +) +def test_byte_order_default_per_model( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model}) + assert config["byte_order"] == byte_order + assert config["byte_order"].enum_value is word_swap + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "big_endian", False), + ("em4m", "LITTLE_ENDIAN", True), + ("em4m", "lsrf", True), + ("em2m", "msrf", False), + ], +) +def test_byte_order_explicit_override( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model, "byte_order": byte_order}) + assert config["byte_order"].enum_value is word_swap + + +def test_byte_order_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + _validate({"byte_order": "middle_endian"}) From e289a944516584213c1adf8a7b3e5eb92a1181ce Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 23:11:21 -0400 Subject: [PATCH 28/66] [friedrich] New Climate IR component (#9178) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/friedrich/__init__.py | 1 + esphome/components/friedrich/climate.py | 32 +++ esphome/components/friedrich/friedrich.cpp | 234 ++++++++++++++++++ esphome/components/friedrich/friedrich.h | 47 ++++ tests/components/friedrich/__init__.py | 15 ++ tests/components/friedrich/common.yaml | 5 + tests/components/friedrich/friedrich_test.cpp | 215 ++++++++++++++++ .../components/friedrich/test.esp32-idf.yaml | 4 + .../friedrich/test.esp8266-ard.yaml | 4 + 10 files changed, 558 insertions(+) create mode 100644 esphome/components/friedrich/__init__.py create mode 100644 esphome/components/friedrich/climate.py create mode 100644 esphome/components/friedrich/friedrich.cpp create mode 100644 esphome/components/friedrich/friedrich.h create mode 100644 tests/components/friedrich/__init__.py create mode 100644 tests/components/friedrich/common.yaml create mode 100644 tests/components/friedrich/friedrich_test.cpp create mode 100644 tests/components/friedrich/test.esp32-idf.yaml create mode 100644 tests/components/friedrich/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f068a3a2215..fc14cb33d09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core +esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio diff --git a/esphome/components/friedrich/__init__.py b/esphome/components/friedrich/__init__.py new file mode 100644 index 00000000000..09f1d1df4fe --- /dev/null +++ b/esphome/components/friedrich/__init__.py @@ -0,0 +1 @@ +DOMAIN = "friedrich" diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py new file mode 100644 index 00000000000..52f1f35708d --- /dev/null +++ b/esphome/components/friedrich/climate.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_SENSOR +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@rwrozelle"] + +friedrich_ns = cg.esphome_ns.namespace("friedrich") +FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) + +Model = friedrich_ns.enum("Model", is_class=True) +MODELS = { + "MW12Y3H": Model.MODEL_MW12Y3H, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( + { + cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True), + # The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it + cv.Optional(CONF_SENSOR): cv.invalid( + "'sensor' is not supported by the Friedrich climate component" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/friedrich/friedrich.cpp b/esphome/components/friedrich/friedrich.cpp new file mode 100644 index 00000000000..b1fcfe06150 --- /dev/null +++ b/esphome/components/friedrich/friedrich.cpp @@ -0,0 +1,234 @@ +#include "friedrich.h" + +#include + +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::friedrich { +// clang-format off +/* +* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation. +* AEHA +* Byte0 Fixed = 0x00 +* Byte1 Fixed = 0x08 +* Byte2 Fixed = 0x08 +* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F +* Byte4 Checksum when command, else Fixed = 0x90 +* Byte5 N/A when command, else Fixed = 0x0C +* Byte6 N/A when command, else Temp(F), add 0x80 for On +* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed) +* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing +* Byte9 N/A when command, else Fixed = 0x00 // not interested +* Byte10 N/A when command, else Fixed = 0x00 // not interested +* Byte11 N/A when command, else Fixed = 0x00 // not interested +* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04) +* Byte13 N/A when command, else CheckSum +*/ +// clang-format on +ESPHOME_LOG_TAG(TAG, "friedrich"); + +static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64; +static constexpr uint8_t TEMP_DEFAULT = 72; +static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5; +static constexpr uint16_t CARRIER_ADDRESS = 0x28C6; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; + +static constexpr uint8_t BYTE3_POWER_OFF = 0x40; +static constexpr uint8_t BYTE6_POWER_ON = 0x80; +static constexpr uint8_t BYTE7_MODE_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_SWING = 0x08; + +// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state +static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = { + 0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00}; +// The off command, including its checksum (255 - byte 3) +static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF, + 255 - BYTE3_POWER_OFF}; + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO}, + {climate::CLIMATE_MODE_COOL, 0x80}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0xC0}, + {climate::CLIMATE_MODE_HEAT, 0x20}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80}, + {climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0}, + {climate::CLIMATE_FAN_QUIET, 0x20}, +}; + +// The entry whose value (or code) matches key. Entries are copied out of PROGMEM. +template static optional find_entry(const Code (&table)[N], uint8_t key, bool match_code) { + for (const Code &slot : table) { + Code entry; + progmem_memcpy(&entry, &slot, sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + return {}; +} + +// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse. +static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; } + +static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) { + for (uint8_t i = from; i <= to; i++) { + if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i])) + return false; + } + return true; +} + +/* + * https://gist.github.com/GeorgeDewar/11171561 + * 1. Reverse (flip) bytes 6 - 13) + * 2. Sum those bytes + * 3. (208 - sum) % 256 + * 4. Reverse (flip) bytes of result + */ +static uint8_t checksum_state(const uint8_t *data) { + uint8_t sum = 0; + for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) { + sum += reverse_bits(data[i]); + } + return reverse_bits(static_cast(208 - sum)); +} + +static const LogString *model_to_string(Model model) { + switch (model) { + case Model::MODEL_MW12Y3H: + default: + return LOG_STR("MW12Y3H"); + } +} + +climate::ClimateTraits FriedrichClimate::traits() { + auto traits = ClimateIR::traits(); + traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT); + return traits; +} + +void FriedrichClimate::setup() { + ClimateIR::setup(); + // The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version. + if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) { + this->target_temperature = TEMP_DEFAULT; + } + this->target_temperature = std::min(this->target_temperature, TEMP_MAX); +} + +void FriedrichClimate::dump_config() { + // ClimateIR::dump_config() is not called: it prints the temperature range in Celsius + LOG_CLIMATE("", "Friedrich Climate", this); + ESP_LOGCONFIG(TAG, + " Temperature range: %u to %u °F\n" + " Model: %s", + TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_))); +} + +void FriedrichClimate::transmit_state() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_off_(); + return; + } + + ESP_LOGV(TAG, "Transmit state"); + + // Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it, + // so the state published after this call matches the frame sent. + const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT; + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, effective_min, TEMP_MAX)); + temperature += temperature % 2; + this->target_temperature = temperature; + + const auto mode = find_entry(MODES, this->mode, false); + const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false); + + uint8_t frame[STATE_MESSAGE_LENGTH]; + progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame)); + frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP); + frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO; + frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO; + if (this->swing_mode != climate::CLIMATE_SWING_OFF) + frame[8] |= BYTE8_FAN_SWING; + frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame); + + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_off_() { + ESP_LOGV(TAG, "Transmit off"); + + uint8_t frame[STATE_MESSAGE_LENGTH_UTIL]; + progmem_memcpy(frame, OFF_FRAME, sizeof(frame)); + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) { + ESP_LOGV(TAG, "Transmit message"); + + auto transmit = this->transmitter_->transmit(); + auto *dst = transmit.get_data(); + dst->set_carrier_frequency(CARRIER_FREQUENCY); + remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len); + transmit.perform(); +} + +bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) { + ESP_LOGV(TAG, "Received message"); + const auto odata = remote_base::AEHAProtocol().decode(src); + if (!odata.has_value()) + return false; + const auto &d = odata->data; + // Other vendors also use AEHA; only accept frames with our address and fixed header bytes. + if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2)) + return false; + + if (d.size() == STATE_MESSAGE_LENGTH_UTIL) { + // Not looking for other types of messages + if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3]) + return false; + ESP_LOGV(TAG, "Received off message"); + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + + if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) || + !matches_template(d.data(), 3, 5)) + return false; + + // Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY) + const auto mode = find_entry(MODES, d[7], true); + if (!mode.has_value()) { + ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]); + return false; + } + this->mode = static_cast(mode->value); + + // State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is + // stripped rather than acted on + const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON; + const uint8_t temp_index = reverse_nibble(temp_code); + if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP) + this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP; + + this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true); + this->fan_mode = fan.has_value() ? static_cast(fan->value) : climate::CLIMATE_FAN_AUTO; + + this->publish_state(); + return true; +} + +} // namespace esphome::friedrich diff --git a/esphome/components/friedrich/friedrich.h b/esphome/components/friedrich/friedrich.h new file mode 100644 index 00000000000..3a0be00f00a --- /dev/null +++ b/esphome/components/friedrich/friedrich.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::friedrich { + +// Extension point for other units: add new models here and branch on model_ where they differ. +// The default must stay MW12Y3H so existing configurations do not change. +enum class Model : uint8_t { + MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit +}; + +class FriedrichClimate : public climate_ir::ClimateIR { + public: + // The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values. + static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating + static constexpr uint8_t TEMP_MAX = 88; + static constexpr uint8_t TEMP_STEP = 2; + static constexpr uint8_t STATE_MESSAGE_LENGTH = 14; + + FriedrichClimate() + : ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + void set_model(Model model) { this->model_ = model; } + + protected: + void setup() override; + void dump_config() override; + climate::ClimateTraits traits() override; + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Transmit via IR power off command. + void transmit_off_(); + + /// Parse incoming message + bool on_receive(remote_base::RemoteReceiveData src) override; + + /// Transmit message as IR pulses + void transmit_(const uint8_t *data, uint8_t len); + + Model model_{Model::MODEL_MW12Y3H}; +}; + +} // namespace esphome::friedrich diff --git a/tests/components/friedrich/__init__.py b/tests/components/friedrich/__init__.py new file mode 100644 index 00000000000..3028881357b --- /dev/null +++ b/tests/components/friedrich/__init__.py @@ -0,0 +1,15 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The AEHA source is compiled only behind its define; climate.py requests it in the real build. + request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/friedrich/common.yaml b/tests/components/friedrich/common.yaml new file mode 100644 index 00000000000..e56be394c59 --- /dev/null +++ b/tests/components/friedrich/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: friedrich + name: Friedrich Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp new file mode 100644 index 00000000000..610eca9c091 --- /dev/null +++ b/tests/components/friedrich/friedrich_test.cpp @@ -0,0 +1,215 @@ +#include +#include + +#include "esphome/components/friedrich/friedrich.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::friedrich::testing { + +using remote_base::AEHAData; +using remote_base::AEHAProtocol; + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +class TestFriedrich : public FriedrichClimate { + public: + using FriedrichClimate::on_receive; + using FriedrichClimate::setup; + using FriedrichClimate::traits; + using FriedrichClimate::transmit_state; +}; + +constexpr uint16_t ADDRESS = 0x28C6; +constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan +constexpr uint8_t TEMP_72_CODE = 0x06; + +struct Fixture { + TestFriedrich sut; + CapturingTransmitter transmitter; + + Fixture() { this->sut.set_transmitter(&this->transmitter); } + + // The last transmission decoded, or an empty frame if it could not be decoded + AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } + + bool receive(const AEHAData &frame) { + remote_base::RemoteTransmitData tx; + AEHAProtocol().encode(&tx, frame); + return this->sut.on_receive(rx_of(tx.get_data())); + } + + static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) { + return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + } +}; + +// Independent implementation of the 14 byte frame checksum +static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) { + AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}}; + uint8_t sum = 0; + for (int i = 6; i < 13; i++) + sum += reverse_bits(f.data[i]); + f.data[13] = reverse_bits(static_cast(208 - sum)); + return f; +} + +static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; } + +TEST(FriedrichTests, TraitsReportFahrenheit) { + Fixture f; + auto traits = f.sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f); +} + +TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_OFF; + f.sut.transmit_state(); + ASSERT_EQ(f.transmitter.sends, 1); + EXPECT_EQ(f.sent(), off_frame()); +} + +TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 72; + f.sut.fan_mode = climate::CLIMATE_FAN_AUTO; + f.sut.swing_mode = climate::CLIMATE_SWING_OFF; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80)); +} + +TEST(FriedrichTests, OddTargetStepsUpToEven) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 71; + f.sut.transmit_state(); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent +} + +// Codes captured from the remote +TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) { + static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, + 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07}; + for (size_t i = 0; i < sizeof(CODES); i++) { + const auto fahrenheit = static_cast(60 + i * 2); + SCOPED_TRACE(fahrenheit); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = fahrenheit; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20)); + + Fixture g; + EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20))); + EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit); + } +} + +TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F + + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f); +} + +TEST(FriedrichTests, ReceivesStateFrame) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80))); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH); +} + +TEST(FriedrichTests, ReceivesOffFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + EXPECT_TRUE(f.receive(off_frame())); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 80; + EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f); +} + +TEST(FriedrichTests, IgnoresCorruptedFrames) { + struct Case { + const char *name; + AEHAData frame; + std::function corrupt; + }; + const Case cases[] = { + {"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }}, + {"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }}, + {"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }}, + {"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }}, + }; + for (const auto &c : cases) { + SCOPED_TRACE(c.name); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + AEHAData frame = c.frame; + c.corrupt(frame); + EXPECT_FALSE(f.receive(frame)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + } +} + +TEST(FriedrichTests, OwnTransmissionIsAccepted) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY; + f.sut.target_temperature = 76; + f.sut.fan_mode = climate::CLIMATE_FAN_LOW; + f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL; + f.sut.transmit_state(); + + Fixture g; + EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last))); + EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f); + EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) { + Fixture f; + f.sut.setup(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +} // namespace esphome::friedrich::testing diff --git a/tests/components/friedrich/test.esp32-idf.yaml b/tests/components/friedrich/test.esp32-idf.yaml new file mode 100644 index 00000000000..bee19625978 --- /dev/null +++ b/tests/components/friedrich/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + friedrich: !include common.yaml diff --git a/tests/components/friedrich/test.esp8266-ard.yaml b/tests/components/friedrich/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d58d8014944 --- /dev/null +++ b/tests/components/friedrich/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + friedrich: !include common.yaml From 7881dfc5d88bd028f61fce78b46f668db1dfcd1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:29:42 -0500 Subject: [PATCH 29/66] [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/deep_sleep/__init__.py | 67 +++++++++++-------- .../deep_sleep/test_deep_sleep.py | 63 +++++++++++++++++ 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 2b820c9be6c..5f340c60efe 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -143,6 +143,8 @@ def validate_wakeup_pin( processed_pins: list[ConfigType] = [{CONF_PIN: value}] else: processed_pins = list(value) + if not processed_pins: + raise cv.Invalid("'wakeup_pin' needs at least one entry") for i, pin_config in enumerate(processed_pins): # now validate each item @@ -154,29 +156,34 @@ def validate_wakeup_pin( def validate_config(config: ConfigType) -> ConfigType: - # right now only BK72XX supports the list format for wakeup pins - if CORE.is_bk72xx: - if CONF_WAKEUP_PIN_MODE in config: - wakeup_pins = config.get(CONF_WAKEUP_PIN, []) - if len(wakeup_pins) > 1: - raise cv.Invalid( - "You need to remove the global wakeup_pin_mode and define it per pin" - ) - if wakeup_pins: - if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: - raise cv.Invalid( - "Specify wakeup_pin_mode either at the top level under deep_sleep " - "or under the pin entry, not both" - ) - wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) - elif ( - isinstance(config.get(CONF_WAKEUP_PIN), list) - and len(config[CONF_WAKEUP_PIN]) > 1 - ): + wakeup_pins = config.get(CONF_WAKEUP_PIN) + has_pin_mode = CONF_WAKEUP_PIN_MODE in config + if has_pin_mode and wakeup_pins is None: raise cv.Invalid( - "Your platform does not support providing multiple entries in wakeup_pin" + "'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE] ) - + multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1 + # wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list + if not CORE.is_bk72xx: + if multiple_pins: + raise cv.Invalid( + "ESP32 does not support multiple entries for 'wakeup_pin'", + path=[CONF_WAKEUP_PIN], + ) + elif has_pin_mode: + if multiple_pins: + raise cv.Invalid( + "Remove the global 'wakeup_pin_mode' and define it per pin", + path=[CONF_WAKEUP_PIN_MODE], + ) + if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + raise cv.Invalid( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both", + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # Move the global mode into the single pin entry, which to_code() reads + pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) return config @@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: if CORE.is_bk72xx: max_duration = core.TimePeriod(hours=36) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX") elif CORE.using_zephyr: max_duration = core.TimePeriod(days=49) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") + raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr") return value @@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, _validate_sleep_duration, ), - cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, + cv.Optional(CONF_WAKEUP_PIN): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + validate_wakeup_pin, + ), cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), cv.enum(WAKEUP_PIN_MODES, upper=True), @@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None: if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) - if CONF_WAKEUP_PIN in config: - pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins(len(pins_as_list))) - for item in pins_as_list: + cg.add(var.init_wakeup_pins(len(wakeup_pins))) + for item in wakeup_pins: cg.add( var.add_wakeup_pin( await cg.gpio_pin_expression(item[CONF_PIN]), @@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e68b1d17cc1..e05109f246a 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( assert ( result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" ) + + +def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None: + """An empty wakeup_pin list is rejected at validation instead of crashing codegen.""" + with pytest.raises(cv.Invalid, match="at least one entry"): + deep_sleep.validate_wakeup_pin([]) + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32 the same mode-only config is rejected too, instead of being silently dead.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +@pytest.mark.parametrize( + "platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR] +) +def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + """wakeup_pin is rejected where the C++ component has no wakeup pin support.""" + set_core_config(platform) + with pytest.raises(cv.Invalid, match="only available on"): + deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"}) + + +def test_deep_sleep_esp32_multiple_wakeup_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """ESP32 accepts only one wakeup_pin entry.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]} + with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN] + + +def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE", + } + with pytest.raises(cv.Invalid, match="define it per pin") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] From 25843c5242cfabe600f3e7262f869734cc5a3a43 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 10:16:44 +0200 Subject: [PATCH 30/66] [image] Implement RGB111 format (#20114) Co-authored-by: J. Nick Koston --- esphome/components/image/__init__.py | 92 +++++++++++++++---- esphome/components/image/image.cpp | 35 +++++++ esphome/components/image/image.h | 2 + esphome/components/lvgl/__init__.py | 17 ++++ .../runtime_image/runtime_image.cpp | 16 ++++ tests/component_tests/image/test_init.py | 90 ++++++++++++++++++ tests/component_tests/lvgl/test_validation.py | 54 ++++++++++- tests/components/image/common.yaml | 16 ++++ tests/components/online_image/common.yaml | 11 +++ 9 files changed, 313 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index eaee31a1c7b..668a78f3cbf 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -145,13 +145,42 @@ def is_alpha_only(image: Image): return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) -class ImageBinary(ImageEncoder): +class BitPackedEncoder(ImageEncoder): + """Packs pixels MSB first, with every row padded to a whole byte.""" + + def __init__( + self, + bpp: int, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + self.bpp = bpp + super().__init__( + (width * bpp + 7) // 8, height, transparency, dither, invert_alpha + ) + self.bitno = 0 + + def add_bit(self, bit: bool) -> None: + if bit: + self.data[self.index] |= 0x80 >> self.bitno + self.bitno += 1 + if self.bitno == 8: + self.end_row() + + def end_row(self) -> None: + if self.bitno != 0: + self.bitno = 0 + self.index += 1 + + +class ImageBinary(BitPackedEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): - self.width8 = (width + 7) // 8 - super().__init__(self.width8, height, transparency, dither, invert_alpha) - self.bitno = 0 + super().__init__(1, width, height, transparency, dither, invert_alpha) def convert(self, image, path): if is_alpha_only(image): @@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder): return image.convert("1", dither=self.dither) def encode(self, pixel): - if self.invert_alpha: - pixel = not pixel - if pixel: - self.data[self.index] |= 0x80 >> (self.bitno % 8) - self.bitno += 1 - if self.bitno == 8: - self.bitno = 0 - self.index += 1 + self.add_bit(bool(pixel) != self.invert_alpha) - def end_row(self): - """ - Pad rows to a byte boundary - """ - if self.bitno != 0: - self.bitno = 0 - self.index += 1 + +# The eight colours one bit per channel can express, for dithering. +_RGB111_PALETTE = Image.new("P", (1, 1)) +_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"]) + + +class ImageRGB111(BitPackedEncoder): + allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL} + + def __init__( + self, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3 + super().__init__(bpp, width, height, transparency, dither, invert_alpha) + + def convert(self, image: Image, path: str): + image = image.convert("RGBA") + if self.dither != Image.Dither.FLOYDSTEINBERG: + return image + alpha = image.getchannel("A") + image = image.convert("RGB").quantize( + palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG + ) + image = image.convert("RGB") + image.putalpha(alpha) + return image + + def encode(self, pixel: tuple[int, int, int, int]): + r, g, b, a = pixel + if self.invert_alpha: + a ^= 0xFF + for channel in (r, g, b, a)[: self.bpp]: + self.add_bit(channel > 127) class ImageGrayscale(ImageEncoder): @@ -315,6 +368,7 @@ class ReplaceWith: IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, + "RGB111": ImageRGB111, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index bfe311be284..d27d110248c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,6 +3,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +ESPHOME_LOG_TAG(TAG, "image"); + namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { @@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color } break; } + case IMAGE_TYPE_RGB111: { + for (int img_x = img_x0; img_x < w; img_x++) { + for (int img_y = img_y0; img_y < h; img_y++) { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = img_x0; img_x < w; img_x++) { for (int img_y = img_y0; img_y < h; img_y++) { @@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off if (this->get_binary_pixel_(x, y)) return color_on; return color_off; + case IMAGE_TYPE_RGB111: + return this->get_rgb111_pixel_(x, y); case IMAGE_TYPE_GRAYSCALE: return this->get_grayscale_pixel_(x, y); case IMAGE_TYPE_RGB565: @@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; + case IMAGE_TYPE_RGB111: + // Rejected by the lvgl config validation + ESP_LOGE(TAG, "RGB111 images are not supported in LVGL."); + break; case IMAGE_TYPE_RGB: switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: @@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const { const uint32_t pos = x + y * width_8; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Image::get_rgb111_pixel_(int x, int y) const { + const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_; + const uint8_t *byte = this->data_start_ + bitpos / 8; + const uint8_t shift = bitpos % 8; + // A pixel can straddle two bytes; read the second one only when it does. + uint16_t window = progmem_read_byte(byte) << 8; + if (shift + this->bpp_ > 8) + window |= progmem_read_byte(byte + 1); + const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1); + const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits; + const bool opaque = this->bpp_ == 3 || (bits & 1); + return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); +} Color Image::get_rgb_pixel_(int x, int y) const { const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), @@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T case IMAGE_TYPE_BINARY: this->bpp_ = 1; break; + case IMAGE_TYPE_RGB111: + this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3; + break; case IMAGE_TYPE_GRAYSCALE: this->bpp_ = 8; break; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index fd9e92c21d3..a94f8137f41 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -13,6 +13,7 @@ enum ImageType { IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB = 2, IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper }; enum Transparency { @@ -44,6 +45,7 @@ class Image : public display::BaseImage { #endif protected: bool get_binary_pixel_(int x, int y) const; + Color get_rgb111_pixel_(int x, int y) const; Color get_rgb_pixel_(int x, int y) const; Color get_rgb565_pixel_(int x, int y) const; Color get_grayscale_pixel_(int x, int y) const; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9b2f41e0635..6e3087ba9f0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -25,6 +25,7 @@ from esphome.components.image import ( ImageBinary, ImageGrayscale, ImageRGB, + ImageRGB111, ImageRGB565, get_image_metadata, ) @@ -43,6 +44,7 @@ from esphome.const import ( CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -293,6 +295,21 @@ def final_validation(config_list): if len(config_list) != 1: multi_conf_validate(config_list) + for img in get_lv_images_used(): + path = global_config.get_path_for_id(img.id)[:-1] + # An image nested in another platform (sendspin's current_image) takes its type from the parent + while path: + image_conf = global_config.get_config_for_path(path) + if isinstance(image_conf, dict) and CONF_TYPE in image_conf: + break + path = path[:-1] + else: + continue + if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111: + raise cv.FinalExternalInvalid( + f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead", + path + [CONF_TYPE], + ) for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 6c101945746..79d2627a11c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { } break; } + case image::IMAGE_TYPE_RGB111: { + uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7); + if (this->bpp_ == 4) + bits = bits << 1 | (color.w >> 7); + const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u; + const uint32_t bitpos = y * stride * 8 + x * this->bpp_; + uint8_t *byte = this->buffer_ + bitpos / 8; + // Place the pixel in a 16-bit window so it may straddle two bytes. + const uint8_t shift = 16 - this->bpp_ - bitpos % 8; + const uint16_t mask = ((1u << this->bpp_) - 1) << shift; + const uint16_t value = bits << shift; + byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8); + if (mask & 0xFF) + byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF); + break; + } case image::IMAGE_TYPE_GRAYSCALE: { uint32_t pos = this->get_position_(x, y); auto gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index fad8b7df093..4d62bb52a19 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -102,6 +102,16 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb111", + "transparency": "chroma_key", + }, + "Image format 'RGB111' cannot have transparency: chroma_key", + id="rgb111_with_chroma_key_transparency", + ), ], ) def test_file_platform_configuration_errors( @@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame( assert all(a == 0x00 for a in frame1_alpha), ( f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "mode", + "transparency", + "frame0_color", + "frame1_color", + "frame0_row", + "frame1_row", + ), + [ + pytest.param( + "RGB", + CONF_OPAQUE, + (255, 0, 0), + (0, 0, 255), + [0x92, 0x48], + [0x24, 0x92], + id="rgb111", + ), + pytest.param( + "RGBA", + CONF_ALPHA_CHANNEL, + (255, 0, 0, 255), + (0, 0, 255, 0), + [0x99, 0x99, 0x90], + [0x22, 0x22, 0x20], + id="rgb111_alpha", + ), + ], +) +async def test_rgb111_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, + mode: str, + transparency: str, + frame0_color: tuple[int, ...], + frame1_color: tuple[int, ...], + frame0_row: list[int], + frame1_row: list[int], +) -> None: + """RGB111 packs one bit per channel MSB first and pads every row to a byte, + so each frame is a self-contained block of row_stride * height bytes. + """ + # A width that is not a multiple of 8 leaves padding bits in every row. + width = 5 + height = 3 + frame0 = PILImage.new(mode, (width, height), frame0_color) + frame1 = PILImage.new(mode, (width, height), frame1_color) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB111", + CONF_TRANSPARENCY: transparency, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + frame_size = len(frame0_row) * height + assert data == frame0_row * height + frame1_row * height, ( + f"Expected {frame_size} row-padded bytes per frame, got {data}" + ) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py index 9a767c0dae5..31cbccb9ff9 100644 --- a/tests/component_tests/lvgl/test_validation.py +++ b/tests/component_tests/lvgl/test_validation.py @@ -2,14 +2,23 @@ from __future__ import annotations +from contextlib import nullcontext + import pytest from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER from esphome.components.display import add_metadata from esphome.components.lvgl import final_validation +from esphome.components.lvgl.defines import get_lv_images_used from esphome.config import Config from esphome.config_validation import Invalid -from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ID, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, ID from esphome.final_validate import full_config @@ -175,3 +184,46 @@ class TestDrawRoundingMerge: configs = [_make_lvgl_config(["my_disp"])] final_validation(configs) assert configs[0]["draw_rounding"] == 2 + + +class TestImageTypeValidation: + """Test the image types LVGL accepts.""" + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_image_type(self, image_type: str, expectation) -> None: + image_id = ID("my_image", True) + fc = full_config.get() + fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}] + fc.declare_ids.append((image_id, ["image", 0, CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None: + """A sendspin artwork image declares its ID below the slot that holds the type.""" + image_id = ID("album_art", True) + fc = full_config.get() + fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}] + fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 5a8f9383198..6b4978fc548 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -10,6 +10,22 @@ image: type: BINARY transparency: chroma_key + - platform: file + id: rgb111_image + file: ../../pnglogo.png + type: RGB111 + - platform: file + id: rgb111_alpha_image + file: ../../pnglogo.png + type: RGB111 + transparency: alpha_channel + - platform: file + id: rgb111_dither_alpha_image + file: ../../pnglogo.png + type: RGB111 + dither: FloydSteinberg + transparency: alpha_channel + - platform: file id: rgba_image file: ../../pnglogo.png diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index df8f85596f8..2a20fa25373 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -37,6 +37,17 @@ image: format: PNG type: RGB transparency: chroma_key + - platform: online_image + id: online_rgb111_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + format: png + - platform: online_image + id: online_rgb111_transparent_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + transparency: alpha_channel + format: png - platform: online_image id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp From 77c9fa2fa4557c332ea2aebd9097482a1a9fa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:43:02 +0200 Subject: [PATCH 31/66] [emontx] Read the emonTx firmware version (#20619) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 71 ++++++++++- esphome/components/emontx/emontx.h | 13 ++ .../components/emontx/text_sensor/__init__.py | 23 ++++ esphome/core/defines.h | 1 + tests/components/emontx/common.yaml | 6 + .../components/emontx/text_sensor/__init__.py | 11 ++ .../text_sensor/emontx_version_test.cpp | 116 ++++++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 esphome/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/emontx_version_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 14dc89e56ae..9c8436c0070 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -1,19 +1,32 @@ #include "emontx.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/json/json_util.h" namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +#ifdef USE_EMONTX_VERSION +static constexpr uint32_t VERSION_REQUEST_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so 'v' waits +static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" +static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +#endif + void EmonTx::setup() { this->buffer_pos_ = 0; - if (!this->lock_on_reboot_) - return; - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); - this->write_byte('\n'); - this->send_command("emonunlock"); + if (this->lock_on_reboot_) { + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); + } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } void EmonTx::on_shutdown() { @@ -48,6 +61,11 @@ void EmonTx::loop() { uint8_t dropped; while (this->available() > 0 && this->read_byte(&dropped)) this->discarding_line_ = dropped != '\n'; +#ifdef USE_EMONTX_VERSION + // The emonTx may have been flashed, or a request dropped, during the pause + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } // Read all available data to prevent UART buffer overflow @@ -77,6 +95,10 @@ void EmonTx::loop() { ESP_LOGV(TAG, "Line is JSON, parsing..."); this->parse_json_(this->buffer_.data(), len); } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->parse_version_line_(this->buffer_.data(), len); +#endif } } else if (received == '\r' || this->discarding_line_) { // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause @@ -102,6 +124,16 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif +#ifdef USE_EMONTX_VERSION + auto msg = root["MSG"]; + if (this->version_text_sensor_ != nullptr && msg.is()) { + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + uint32_t msg_number = msg; + if (msg_number <= this->last_msg_number_) + this->request_version_(); + this->last_msg_number_ = msg_number; + } +#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -112,6 +144,30 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } +#ifdef USE_EMONTX_VERSION +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::request_version_() { + if (this->is_paused()) + return; + this->write_byte('\n'); + this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_version_line_(const char *line, size_t len) { + constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; + if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + return; + const char *version = line + prefix_len; + const char *end = line + len; + while (version < end && *version == ' ') + version++; + if (version == end) + return; + ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); + this->version_text_sensor_->publish_state(version, end - version); +} +#endif + /** * @brief Logs the EmonTx component configuration details. */ @@ -127,6 +183,9 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif +#ifdef USE_EMONTX_VERSION + LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); +#endif } /** diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index bcec7ce171d..8f6a2424c94 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,6 +13,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_EMONTX_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::emontx { @@ -27,6 +30,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { +#ifdef USE_EMONTX_VERSION + SUB_TEXT_SENSOR(version) +#endif public: EmonTx() = default; @@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); +#ifdef USE_EMONTX_VERSION + void request_version_(); + void parse_version_line_(const char *line, size_t len); +#endif #ifdef USE_SENSOR FixedVector> sensors_{}; @@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice { bool was_paused_{false}; bool lock_on_reboot_{false}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line +#ifdef USE_EMONTX_VERSION + uint32_t last_msg_number_{0}; +#endif std::array buffer_{}; }; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..9b3065f0f86 --- /dev/null +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, EmonTx + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: + cg.add_define("USE_EMONTX_VERSION") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a7d2084a540..d9b12fdddb1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -68,6 +68,7 @@ #define USE_DEVICES #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 +#define USE_EMONTX_VERSION #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index e609a217e0e..4a5e347053a 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -24,3 +24,9 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..60a67b5a0b6 --- /dev/null +++ b/tests/components/emontx/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_EMONTX_VERSION") + + manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp new file mode 100644 index 00000000000..fe905a0e958 --- /dev/null +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "esphome/components/emontx/emontx.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxVersionTest : public ::testing::Test { + protected: + void SetUp() override { + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_version_text_sensor(&this->version_); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); + App.scheduler.call(millis()); + } + + // Lets the delayed 'v' go out + static void run_timers() { + App.scheduler.call(millis()); + delay(300); // NOLINT - test only, the scheduler reads the real clock + App.scheduler.call(millis()); + } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + + MockUARTComponent uart_; + EmonTx sut_; + text_sensor::TextSensor version_; +}; +} // namespace + +// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command +TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n"); +} + +TEST_F(EmonTxVersionTest, VersionLinePublished) { + this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n"); + this->sut_.loop(); + + ASSERT_TRUE(this->version_.has_state()); + EXPECT_EQ(this->version_.get_state(), "v1.1.1"); +} + +TEST_F(EmonTxVersionTest, OtherLinesNotPublished) { + this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n"); + this->sut_.loop(); + + EXPECT_FALSE(this->version_.has_state()); +} + +// MSG restarts at 1 after a reboot, e.g. a firmware update +TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { + this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->uart_.push_rx("{\"MSG\":1}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +// The emonTx may have been flashed during the pause, with MSG already past its old value +TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) { + this->sut_.setup(); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n"); + + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n\nv\n"); +} + +} // namespace esphome::emontx::testing From 192ae31e454a022d858aed017dc646d7737cf5bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:19:10 -1000 Subject: [PATCH 32/66] [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) --- tests/unit_tests/test_core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 94458109753..ff36f1da18e 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -4,7 +4,7 @@ import subprocess import sys from unittest.mock import patch -from hypothesis import given +from hypothesis import given, settings import pytest from esphome import const, core @@ -31,6 +31,7 @@ class TestHexInt: class TestMACAddress: + @settings(deadline=None) @given(value=mac_addr_strings()) def test_init__valid(self, value): core.MACAddress(*value.split(":")) @@ -40,6 +41,7 @@ class TestMACAddress: with pytest.raises(ValueError, match="MAC Address must consist of 6 items"): core.MACAddress(*value.split(":")) + @settings(deadline=None) @given(value=mac_addr_strings()) def test_str(self, value): target = core.MACAddress(*(int(v, 16) for v in value.split(":"))) From fd541bca2cfd7755f036f9f8b824a5408fec5ccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:20:32 -1000 Subject: [PATCH 33/66] [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5031c4a457f..e00888b5f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting * **C++ Preprocessor Directives:** * **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums. + * **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope + and inside functions; `static const` is only for a value computed at run time. A function-local + `static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too. * **Use `#define` only for:** - Conditional compilation (`#ifdef`, `#ifndef`) - Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`) @@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting listener base (a second vptr per entity). * **A global feature define** says the code is compiled in, not that this instance uses it: keep the per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. + * **Model-specific code** sits behind a define the Python side emits only for configs that use that model + (`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check + stays inside the `#ifdef`. * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a @@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** + * **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...}, + key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator + that rejects keys and fills defaults afterwards. * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs on. A variant per board for platform-identical code only adds CI time. + * **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option + that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to + `ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-` file. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ From 29a668af0443ab80e29fa29753a56160bbd57db3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:17 +0000 Subject: [PATCH 34/66] [esp32_camera] Fix compile warning (#20628) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32_camera/esp32_camera.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index ef29fc9b25d..a36fc763a68 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -282,7 +282,7 @@ void ESP32Camera::loop() { } this->frame_count_++; if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) { - ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); + ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); this->last_log_time_ = now; this->frame_count_ = 0; } From c1c1d2b2ae7d199b7579c99e93091f7d9fb8107f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 07:28:32 -1000 Subject: [PATCH 35/66] [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) --- esphome/__main__.py | 34 +- esphome/espota2.py | 346 +++++++++++--- tests/unit_tests/test_espota2.py | 4 +- tests/unit_tests/test_espota2_noise.py | 629 ++++++++++++++++++++++++- 4 files changed, 918 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 2b094e67a14..82ef585e1a9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -483,12 +483,9 @@ def has_ota() -> bool: def has_native_ota() -> bool: """Check if native API OTA upload is available (``platform: esphome``).""" - if CONF_OTA not in CORE.config: - return False - return any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - for ota_item in CORE.config[CONF_OTA] - ) + from esphome.espota2 import esphome_ota_items + + return bool(esphome_ota_items(CORE.config)) def has_web_server_ota() -> bool: @@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: return CONF_WEB_SERVER +def _esphome_ota_conf(config: ConfigType) -> ConfigType | None: + """The validated esphome OTA platform block, if configured.""" + from esphome.espota2 import esphome_ota_items + + return next(iter(esphome_ota_items(config)), None) + + def _upload_via_native_api( config: ConfigType, network_devices: list[str], args: ArgsProtocol ) -> tuple[int, str | None]: - ota_conf: ConfigType = {} - for ota_item in config.get(CONF_OTA, []): - if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME: - ota_conf = ota_item - break + ota_conf = _esphome_ota_conf(config) or {} from esphome import espota2 from esphome.components.noise import static_encryption_key @@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None: `upload` may send a stale image that still carries the previous key.""" from esphome import espota2 - for item in config.get(CONF_OTA, []): - if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( - item.get(CONF_ENCRYPTION) or {} - ).get(espota2.CONF_OLD_KEY): + for item in espota2.esphome_ota_items(config): + if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY): _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) @@ -1468,12 +1466,12 @@ def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: from esphome import web_server_ota + from esphome.espota2 import esphome_ota_items from esphome.web_server_helpers import get_web_server_connection if any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - and ota_item.get(CONF_ENCRYPTION) is not None - for ota_item in config.get(CONF_OTA, []) + ota_item.get(CONF_ENCRYPTION) is not None + for ota_item in esphome_ota_items(config) ): _LOGGER.warning( "This config has OTA encryption, but the web_server OTA path sends " diff --git a/esphome/espota2.py b/esphome/espota2.py index d8a2ff9000d..14e163676d1 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable import contextlib +import enum import gzip import hashlib import io @@ -12,8 +13,10 @@ import socket import time from typing import Any +from esphome.const import CONF_ESPHOME, CONF_OTA, CONF_PLATFORM from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.types import ConfigType OTA_TYPE_UPDATE_APP = 0x00 OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 @@ -118,6 +121,7 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +_AUTH_ANSWERS = (*_AUTH_METHODS, RESPONSE_AUTH_OK) # Error response code -> human-readable message (without the "Error: " prefix; check_error() # prepends it uniformly). Looked up by check_error() to translate a single byte from the device @@ -224,10 +228,52 @@ class OTAKeyRejected(OTAError): """The Noise handshake failed on the key; another key may succeed.""" +class OTAEncryptionNotOffered(OTAError): + """A key is configured but the device cannot encrypt.""" + + +class ProbeResult(enum.Enum): + """What probe_ota_key found; only ACCEPTED means the device runs the key.""" + + ACCEPTED = "accepted" + REJECTED = "rejected" # the device runs another key + NOT_OFFERED = "not offered" # the device cannot encrypt + DEVICE_ERROR = "device error" # answered, but not about the key + UNREACHABLE = "unreachable" # no answer at all + + def __bool__(self) -> bool: + return self is ProbeResult.ACCEPTED + + +_PROBE_ANSWERS = ( + (OTAKeyRejected, ProbeResult.REJECTED), + (OTAEncryptionNotOffered, ProbeResult.NOT_OFFERED), +) + + +def esphome_ota_items(config: ConfigType) -> list[ConfigType]: + """The esphome ota entries; a raw ``ota:`` may be a mapping, not a list.""" + ota = config.get(CONF_OTA) or [] + items = [ota] if isinstance(ota, dict) else ota + return [ + item + for item in items + if isinstance(item, dict) and item.get(CONF_PLATFORM) == CONF_ESPHOME + ] + + # Uploader side options under `ota: encryption:`; the ota component imports # the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" CONF_OLD_KEY = "old_key" +ENCRYPTION_NOT_OFFERED_ADVICE = ( + "Refusing to send the image in plaintext. The running firmware predates " + "ESPHome 2026.9.0 or has no 'api: encryption: key'. With an api key, install " + "once without the 'ota: encryption:' block (that build offers encryption), " + f"then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' under " + "'ota: encryption:' for this one install, or flash by serial or the " + "web_server OTA platform." +) ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -429,6 +475,22 @@ def send_check( raise OTANetworkError(f"sending {msg}: {err}") from err +def noise_handshake(psk: str, prologue: bytes) -> Any: + """A fresh NNpsk0 initiator; a bad key or a missing noise stack raises here.""" + # Lazy: the noise stack is only imported for an encrypted upload + try: + from aioesphomeapi.noise import NoiseHandshake + except ImportError as err: + raise OTAError( + "OTA encryption requires a newer aioesphomeapi; update your " + "esphome installation (pip install -U esphome) and retry" + ) from err + try: + return NoiseHandshake(psk, prologue) + except ValueError as err: + raise OTAError(f"Invalid OTA encryption key: {err}") from err + + class NoiseSocketWrapper: """Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport. @@ -439,25 +501,12 @@ class NoiseSocketWrapper: """ def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None: - # Deliberately lazy: the noise stack (noiseprotocol, cryptography) is - # only imported when an encrypted upload actually runs. - try: - from aioesphomeapi.noise import NoiseHandshake - except ImportError as err: - raise OTAError( - "OTA encryption requires a newer aioesphomeapi; update your " - "esphome installation (pip install -U esphome) and retry" - ) from err - # The aioesphomeapi import above already loaded cryptography; bind - # the exception once so recv() pays no per-frame import lookup + self._handshake = noise_handshake(psk, prologue) + # Bound once; recv() must not import per frame from cryptography.exceptions import InvalidTag self._invalid_tag = InvalidTag self._sock = sock - try: - self._handshake = NoiseHandshake(psk, prologue) - except ValueError as err: - raise OTAError(f"Invalid OTA encryption key: {err}") from err self._encrypt = None self._decrypt = None self._buffer = b"" @@ -564,43 +613,20 @@ class NoiseSocketWrapper: return data -def perform_ota( +def _negotiate_session( sock: socket.socket, - password: str | None, - file_handle: io.IOBase, - filename: Path, - ota_type: int = OTA_TYPE_UPDATE_APP, - noise_psk: str | None = None, + noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> None: - # Validate up front; an out-of-range value would only surface as a - # ValueError deep inside send_check, bypassing OTAError handling - if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: - raise OTAError( - f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" - ) - if ota_type not in _SUPPORTED_OTA_TYPES: - supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) - raise OTAError( - f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" - ) - - if noise_psk is not None and not noise_psk: - raise OTAError( - "An empty OTA encryption key was provided; refusing to upload in plaintext" - ) - - file_contents = file_handle.read() - file_size = len(file_contents) - _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: + """Negotiate through the Noise handshake; returns the socket to go on with + (wrapped once encrypted), version, features, extended.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.info("Connection established; device supports OTA version %s", version) + _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -656,15 +682,9 @@ def perform_ota( # capture the image (wifi credentials, api key) # Remove before 2027.3.0: installing without the block no longer # falls back then; advise 'allow_plaintext_upload: true' instead - raise OTAError( + raise OTAEncryptionNotOffered( "An OTA encryption key is configured but the device did not " - "offer encryption; refusing to send the image in plaintext. " - "The running firmware predates ESPHome 2026.9.0 or has no " - "'api: encryption: key'. With an api key, install once " - "without the 'ota: encryption:' block (that build offers " - f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " - "true' under 'ota: encryption:' for this one install, or flash by " - "serial or the web_server OTA platform." + "offer encryption" ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -682,9 +702,50 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - _LOGGER.info("Encrypted connection established") + _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) + return sock, version, features, extended_proto + + +def perform_ota( + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, + noise_psk: str | None = None, + plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, + on_upload_start: Callable[[], None] | None = None, +) -> None: + # Validate up front; an out-of-range value would only surface as a + # ValueError deep inside send_check, bypassing OTAError handling + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + + if noise_psk is not None and not noise_psk: + raise OTAError( + "An empty OTA encryption key was provided; refusing to upload in plaintext" + ) + + file_contents = file_handle.read() + file_size = len(file_contents) + _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + + sock, version, features, extended_proto = _negotiate_session( + sock, noise_psk, plaintext_fallback, allow_plaintext_upload + ) + _LOGGER.info("Connection established; device supports OTA version %s", version) + if isinstance(sock, NoiseSocketWrapper): + _LOGGER.info("Encrypted connection established") if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -761,18 +822,15 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - (auth,) = receive_exactly( - sock, - 1, - "auth", - [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], - ) + (auth,) = receive_exactly(sock, 1, "auth", list(_AUTH_ANSWERS)) if auth != RESPONSE_AUTH_OK: hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) _LOGGER.info("Handshake complete") + if on_upload_start is not None: + on_upload_start() # the device may commit anything sent from here sock.settimeout(DATA_PHASE_TIMEOUT) @@ -890,6 +948,146 @@ def perform_ota( time.sleep(1) +PROBE_TIMEOUT = 120.0 +PROBE_RETRY_DELAY = 3.0 +PROBE_CONNECT_TIMEOUT = 3.0 +PROBE_HANDSHAKE_TIMEOUT = 10.0 + + +def _resolve_targets(remote_host: str | list[str], remote_port: int) -> list[Any]: + """Every address to try for the host, through the cache; OTAError when + there is none.""" + from esphome.core import CORE + + try: + res = resolve_ip_address( + remote_host, remote_port, address_cache=CORE.address_cache + ) + except EsphomeError as err: + raise OTAError(err) from err + if not res: + raise OTAError(f"No addresses to connect to for {remote_host}") + return res + + +class _DeadlineSocket: + """Re-arms the timeout before every operation so the whole negotiation, + not each read, ends by the deadline.""" + + def __init__(self, sock: socket.socket, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _arm(self) -> None: + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("probe deadline reached") + self._sock.settimeout(min(PROBE_HANDSHAKE_TIMEOUT, remaining)) + + def recv(self, amount: int) -> bytes: + self._arm() + return self._sock.recv(amount) + + def sendall(self, data: bytes) -> None: + self._arm() + self._sock.sendall(data) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def close(self) -> None: + self._sock.close() + + +def probe_ota_key( + remote_host: str | list[str], + remote_port: int, + noise_psk: str, + *, + timeout: float = PROBE_TIMEOUT, + retry_rejected: bool = True, +) -> ProbeResult: + """Whether the device completes a Noise handshake with the key, no image + sent. Retries until the deadline, a rejection too with ``retry_rejected`` + (the old firmware may answer once more after an upload).""" + + noise_handshake(noise_psk, b"") # a local fault must not read as the device's no + deadline = time.monotonic() + timeout + + def pause(delay: float) -> None: + time.sleep(max(0.0, min(delay, deadline - time.monotonic()))) + + res: list[Any] = [] + answered: set[Any] = set() # addresses whose answer no retry changes + last_error = "no time left" + outcome = ProbeResult.UNREACHABLE # a device answer outranks a network error + while time.monotonic() < deadline: + if not res: + # A rebooting device may drop off mDNS for a while; keep asking. + # Addresses the caller pre-resolved (the address cache, or a + # literal IP) are its to keep; only a hostname is looked up again + try: + res = _resolve_targets(remote_host, remote_port) + except OTAError as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = str(err) + _LOGGER.debug("Host not resolved yet (%s); retrying", err) + pause(PROBE_RETRY_DELAY) + continue + # One round: every address that has not answered, back to back + started = time.monotonic() + unreached = 0 + for af, socktype, _, _, sa in res: + if sa in answered: + continue + if (remaining := deadline - time.monotonic()) <= 0: + break + sock = socket.socket(af, socktype) + # A dead host must not eat the budget + sock.settimeout(min(PROBE_CONNECT_TIMEOUT, remaining)) + with contextlib.closing(sock): + try: + sock.connect(sa) + session = _negotiate_session( + _DeadlineSocket(sock, deadline), noise_psk + )[0] + # A password challenge also proves the key: it comes encrypted + receive_exactly(session, 1, "auth", list(_AUTH_ANSWERS)) + except (OSError, OTANetworkError) as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = f"{sa[0]}: {err}" + unreached += 1 + except OTAError as err: + # Final in the precheck; a rebooting device may answer + # differently once it is back + found = next( + (r for t, r in _PROBE_ANSWERS if isinstance(err, t)), + ProbeResult.DEVICE_ERROR, + ) + # A stale address answering with some other error must + # not hide what the device said about the key + if found is not ProbeResult.DEVICE_ERROR or not any( + outcome is r for _, r in _PROBE_ANSWERS + ): + outcome, last_error = found, f"{sa[0]}: {err}" + if not retry_rejected: + answered.add(sa) + else: + _LOGGER.info("Device %s accepted the key", sa[0]) + return ProbeResult.ACCEPTED + _LOGGER.debug("Key not accepted yet (%s); retrying", last_error) + # Every address gets its own final say; a stale one cannot answer + # for the device + pending = sum(1 for *_, sa in res if sa not in answered) + if pending == 0 or time.monotonic() >= deadline: + break + if unreached == pending: + res = [] # no answer this round: the device may have a new address + pause(PROBE_RETRY_DELAY - (time.monotonic() - started)) + _LOGGER.warning("Checking the key failed, %s: %s", outcome.value, last_error) + return outcome + + def run_ota_impl_( remote_host: str | list[str], remote_port: int, @@ -900,16 +1098,13 @@ def run_ota_impl_( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE - # Handle both single host and list of hosts try: - # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address( - remote_host, remote_port, address_cache=CORE.address_cache - ) - except EsphomeError as err: + res = _resolve_targets(remote_host, remote_port) + except OTAError: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", remote_host, @@ -920,11 +1115,7 @@ def run_ota_impl_( "(If this error persists, please set a static IP address: " "https://esphome.io/components/wifi/#manual-ips)" ) - raise OTAError(err) from err - - if not res: - _LOGGER.error("No addresses to connect to for %s", remote_host) - return 1, None + raise # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries # are shared across the addresses, cycling through them. Wait before an @@ -941,6 +1132,13 @@ def run_ota_impl_( reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) + + def upload_started() -> None: + nonlocal on_upload_start + if on_upload_start is not None: + on_upload_start() + on_upload_start = None # once per upload, whatever the retries do + while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -978,6 +1176,7 @@ def run_ota_impl_( encryption.noise_psk, encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + on_upload_start=upload_started, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1013,6 +1212,8 @@ def run_ota_impl_( # Device-reported error (wrong password, wrong flash size, ...); # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) + if isinstance(err, OTAEncryptionNotOffered): + _LOGGER.error(ENCRYPTION_NOT_OFFERED_ADVICE) return 1, None if encryption.tried_old_key: @@ -1038,7 +1239,11 @@ def run_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: + """``on_upload_start`` runs once, when the device first accepted the + session and the image is about to go out: the point from which it may + commit, which a later retry or failure does not undo.""" try: return run_ota_impl_( remote_host, @@ -1050,6 +1255,7 @@ def run_ota( plaintext_fallback, allow_plaintext_upload, old_noise_psk, + on_upload_start, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f1e04da8c87..e83335a175f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -964,10 +964,10 @@ def test_run_ota_impl_device_error_not_retried( def test_run_ota_impl_no_addresses( firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + """An empty resolution is reported once and fails cleanly.""" mock_resolve_ip.return_value = [] - result_code, result_host = espota2.run_ota_impl_( + result_code, result_host = espota2.run_ota( "test.local", 3232, "password", str(firmware_file) ) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index ca50641309b..1adace67395 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -13,6 +13,7 @@ import base64 from collections.abc import Callable import hashlib import io +import itertools import logging from pathlib import Path import socket @@ -24,11 +25,16 @@ from unittest.mock import Mock, patch import pytest from esphome import espota2 +from esphome.core import EsphomeError +from esphome.espota2 import ProbeResult PSK = base64.b64encode(bytes(range(32))).decode() OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode() MAGIC = bytes(espota2.MAGIC_BYTES) +# One resolved loopback address, as resolve_ip_address returns it +RESOLVED = [(2, 1, 0, "", ("127.0.0.1", 1))] +TWO_ADDRESSES = [(2, 1, 0, "", ("10.0.0.1", 1)), (2, 1, 0, "", ("10.0.0.2", 1))] def _recv_exact(sock: socket.socket, amount: int) -> bytes: @@ -82,6 +88,7 @@ class FakeEncryptedDevice(threading.Thread): self.require_noise = require_noise self.prologue_features_override = prologue_features_override self.received: bytes | None = None + self.probes = 0 # clients that left right after the handshake self.error: Exception | None = None self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) @@ -195,7 +202,12 @@ class FakeEncryptedDevice(threading.Thread): ) -> None: """The post-handshake exchange, identical over both transports.""" send_byte(espota2.RESPONSE_AUTH_OK) - recv_unit(1) # ota type + try: + recv_unit(1) # ota type + except ConnectionError: + # A key probe leaves here, like the firmware it is patterned on + self.probes += 1 + return size = int.from_bytes(recv_unit(4), "big") send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK) md5_hex = recv_unit(32) @@ -250,6 +262,7 @@ def _run_ota( noise_psk: str, plaintext_fallback: bool = True, old_noise_psk: str | None = None, + **kwargs: Any, ) -> int: """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" @@ -263,10 +276,110 @@ def _run_ota( noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, old_noise_psk=old_noise_psk, + **kwargs, ) return rc +def test_on_upload_start_runs_once_the_device_accepted_the_session( + tmp_path: Path, +) -> None: + """The rejected first key never reaches the hook; the old_key reconnect does.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, connections=2) + on_upload_start = Mock() + with patch("time.sleep"): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=OTHER_PSK, + on_upload_start=on_upload_start, + ) + device.join_and_check() + assert rc == 0 + on_upload_start.assert_called_once() + + +def test_on_upload_start_fires_once_across_retries() -> None: + """A data phase fault after the hook retries the upload; the hook has + already reported that the device may commit.""" + calls: list[int] = [] + + def perform(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + if len(calls) == 0: + calls.append(1) + raise espota2.OTANetworkError("reset") + + hook = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=perform), + patch("time.sleep"), + ): + rc, _ = espota2.run_ota_impl_( + "h", 1, None, Path("fw.bin"), on_upload_start=hook + ) + assert rc == 0 + hook.assert_called_once() + + +def test_probe_result_is_true_only_when_accepted() -> None: + assert ProbeResult.ACCEPTED + assert not any(r for r in ProbeResult if r is not ProbeResult.ACCEPTED) + + +def test_probe_ota_key_keeps_the_key_answer_over_a_stale_device_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address running some other listener answers with its own + error; what the device said about the key still wins.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=TWO_ADDRESSES), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + espota2.OTAError("unsupported OTA version 9"), + ], + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_on_upload_start_failure_closes_the_socket() -> None: + def start_upload(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket") as sock, + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=start_upload), + pytest.raises(RuntimeError, match="boom"), + ): + espota2.run_ota_impl_( + "h", + 1, + None, + Path("fw.bin"), + on_upload_start=Mock(side_effect=RuntimeError("boom")), + ) + sock.return_value.close.assert_called_once() + + THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() @@ -363,9 +476,7 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises( - espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" - ): + with pytest.raises(espota2.OTAEncryptionNotOffered, match="did not offer"): _upload(device, b"firmware", PSK) device.join_and_check() @@ -666,6 +777,120 @@ def test_non_key_reject_reason_is_a_device_error( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +def test_probe_ota_key_accepts_the_running_key( + caplog: pytest.LogCaptureFixture, +) -> None: + """The negotiation's upload-style progress lines stay below INFO.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + with caplog.at_level(logging.INFO): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + assert device.received is None + assert "accepted the key" in caplog.text + assert "Connection established" not in caplog.text + + +@pytest.mark.parametrize( + "device_kwargs", + [{"psk": OTHER_PSK}, {"offer_noise": False, "require_noise": False}], + ids=["wrong_key", "no_offer"], +) +def test_probe_ota_key_gives_up_at_the_deadline( + device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, **device_kwargs) + device.start() + with patch("time.sleep"), caplog.at_level(logging.WARNING): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5) + is not ProbeResult.ACCEPTED + ) + device.join_and_check() + assert any("Checking the key failed" in r.message for r in caplog.records) + assert device.received is None + + +def test_probe_ota_key_fails_fast_on_a_definitive_answer() -> None: + """A device in steady state that rejects the key gives its final answer + on the first attempt; the precheck does not wait out the deadline.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK) + device.start() + with patch("time.sleep") as sleep: + assert ( + espota2.probe_ota_key( + "127.0.0.1", device.port, PSK, timeout=30, retry_rejected=False + ) + is ProbeResult.REJECTED + ) + device.join_and_check() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_after_a_transport_fault() -> None: + """The device may still be rebooting right after the upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, drop_handshakes=1) + device.start() + with patch("time.sleep"): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + + +@pytest.mark.parametrize( + "kwargs", + [{"side_effect": EsphomeError("no such host")}, {"return_value": []}], + ids=["error", "empty"], +) +def test_probe_ota_key_resolution_failure( + kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A name that never resolves is retried until the deadline, then reported.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + is not ProbeResult.ACCEPTED + ) + assert resolve.call_count > 1 + assert any("failed, unreachable" in r.message for r in caplog.records) + + +def test_probe_ota_key_resolves_again_after_a_reboot() -> None: + """The lookup fails while the device reboots, then the probe goes on.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + real = espota2.resolve_ip_address + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[EsphomeError("not yet"), real("127.0.0.1", device.port)], + ), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: @@ -677,5 +902,399 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( device.join_and_check() assert rc == 1 assert device.received != b"firmware" - assert any("refusing to send the image" in r.message for r in caplog.records) + assert any("Refusing to send the image" in r.message for r in caplog.records) assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + +def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: + """A slow connect leaves less for the handshake than the budget had.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep"), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 0.4) + sock.recv.return_value = b"" + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + connect_timeout, handshake_timeout = ( + c.args[0] for c in sock.settimeout.call_args_list[:2] + ) + assert connect_timeout == 0.5 + assert handshake_timeout == pytest.approx(0.1) + + +def test_probe_ota_key_tries_every_address_before_a_final_no() -> None: + """With retry_rejected off a rejection is final only once every resolved + address has answered; a stale cached IP must not veto the real device.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 2 + sleep.assert_not_called() + + +@pytest.mark.parametrize( + "answer", [espota2.RESPONSE_REQUEST_AUTH, espota2.RESPONSE_REQUEST_SHA256_AUTH] +) +def test_probe_ota_key_takes_a_password_challenge_as_proof(answer: int) -> None: + """A device with a password as well as encryption answers the handshake + with a challenge; it arrives encrypted, so the key was accepted.""" + session = Mock() + session.recv.return_value = bytes([answer]) + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch("esphome.espota2._negotiate_session", return_value=(session, 2, 0, True)), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + + +def test_probe_ota_key_waits_for_every_address_to_answer() -> None: + """One address rejecting does not stand in for another that only failed + to connect so far; the one that answered is not asked again.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + OSError("refused"), + (session, 2, 0, True), + ], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 3 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_pauses_between_rounds_once_an_address_answered() -> None: + """A stale address before a device that said no keeps the round pacing: + the device is not asked again and the stale address is not hammered.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ) as resolve, + patch("socket.socket") as sock_cls, + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [OSError("refused"), espota2.OTAKeyRejected("no")], + itertools.repeat(OSError("refused")), + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep) as sleeper, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + # Ten rounds of one stale attempt each, a pause between every two + assert negotiate.call_count == 11 + assert sleeper.call_count == 10 + assert [c.args[0] for c in sock_cls.return_value.connect.call_args_list].count( + ("10.0.0.2", 1) + ) == 1 + # Every later round had no answer, so the host was resolved again each time + assert resolve.call_count == 10 + + +def test_probe_ota_key_keeps_trying_an_address_a_new_lookup_added() -> None: + """An address that answered earlier and is gone from the lookup does not + count against the ones still pending.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], + itertools.repeat([(2, 1, 0, "", ("10.0.0.3", 1))]), + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("no")], itertools.repeat(OSError("down")) + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + assert negotiate.call_count == 11 # the new address was tried every round + + +def test_probe_ota_key_keeps_the_device_answer_when_a_later_lookup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], itertools.repeat(EsphomeError("dns down")) + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), OSError("down")], + ), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_probe_ota_key_names_a_missing_encryption_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The upload's advice about the image does not apply to a probe.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAEncryptionNotOffered("did not offer encryption"), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.NOT_OFFERED + ) + assert "failed, not offered: 127.0.0.1: did not offer encryption" in caplog.text + assert "image" not in caplog.text + + +def test_probe_ota_key_resolves_again_after_a_round_without_an_answer() -> None: + """A round with no answer looks the host up again, and the device is + found under its new address.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[ + [(2, 1, 0, "", ("10.0.0.1", 1))], + [(2, 1, 0, "", ("10.0.0.2", 1))], + ], + ) as resolve, + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[OSError("unreachable"), (session, 2, 0, True)], + ), + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert resolve.call_count == 2 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_reports_the_device_answer_over_a_network_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address failing last must not hide that the device said no.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("wrong key")], + itertools.repeat(OSError("refused")), + ), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.05, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: wrong key" in caplog.text + + +def test_probe_ota_key_reports_a_local_fault_as_its_own() -> None: + """A malformed key never reads as the device refusing it.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address") as resolve, + pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"), + ): + espota2.probe_ota_key("h", 1, "not-base64!!!", timeout=30) + resolve.assert_not_called() + + +def test_probe_ota_key_takes_another_device_answer_as_final() -> None: + """An unsupported protocol version does not change with a retry.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAError("unsupported OTA version 9"), + ) as negotiate, + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.DEVICE_ERROR + ) + negotiate.assert_called_once() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_a_device_error_while_it_reboots() -> None: + """After an upload the device may answer oddly until it is back.""" + session = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAError("busy"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert negotiate.call_count == 2 + + +def test_probe_ota_key_deadline_spans_the_whole_negotiation() -> None: + """Each read re-arms the timeout from what is left, so a slow peer + cannot stretch the probe one full timeout per read.""" + clock = [0.0] + sock = Mock() + + def slow_recv(_amount: int) -> bytes: + clock[0] += 0.3 + return b"\x00\x02" + + sock.recv.side_effect = slow_recv + with patch("time.monotonic", side_effect=lambda: clock[0]): + wrapped = espota2._DeadlineSocket(sock, 0.5) + wrapped.sendall(b"x") + assert wrapped.recv(2) == b"\x00\x02" + assert wrapped.recv(2) == b"\x00\x02" + with pytest.raises(TimeoutError): + wrapped.recv(1) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert timeouts == [0.5, 0.5, pytest.approx(0.2)] + + +def test_probe_ota_key_stops_when_connect_used_the_budget() -> None: + """No handshake timeout is granted past the deadline.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 1.0) + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + assert [c.args[0] for c in sock.settimeout.call_args_list] == [0.5] + sock.sendall.assert_not_called() + sleep.assert_not_called() + + +def test_probe_ota_key_never_waits_past_its_deadline() -> None: + """Connect and handshake timeouts and the retry sleep are all capped by + what is left of the budget.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + ): + sock = sock_cls.return_value + sock.connect.side_effect = OSError("refused") + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert all(t <= 0.5 for t in timeouts) + assert all(c.args[0] <= 0.5 for c in sleep.call_args_list) From 4a80d785a8d95539ba9ccf6ec8ae41ff8fd56d19 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Tue, 13 Oct 2026 19:52:46 +0200 Subject: [PATCH 36/66] =?UTF-8?q?[filter=5Flifetime]=20Add=20component=20w?= =?UTF-8?q?ith=20sensor/lambda=20support=20for=20is=5Fon=20=E2=80=A6=20(#1?= =?UTF-8?q?3914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/filter_lifetime/__init__.py | 2 + .../filter_lifetime/filter_lifetime.cpp | 112 +++++++++ .../filter_lifetime/filter_lifetime.h | 50 ++++ esphome/components/filter_lifetime/sensor.py | 94 ++++++++ tests/components/filter_lifetime/__init__.py | 12 + tests/components/filter_lifetime/common.yaml | 62 +++++ .../filter_lifetime/lambda_only.yaml | 8 + .../test-lambda-only.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp8266-ard.yaml | 2 + .../filter_lifetime/test.rp2040-ard.yaml | 2 + .../filter_lifetime/test_filter_lifetime.cpp | 224 ++++++++++++++++++ .../components/filter_lifetime/__init__.py | 0 .../components/filter_lifetime/test_sensor.py | 99 ++++++++ 15 files changed, 672 insertions(+) create mode 100644 esphome/components/filter_lifetime/__init__.py create mode 100644 esphome/components/filter_lifetime/filter_lifetime.cpp create mode 100644 esphome/components/filter_lifetime/filter_lifetime.h create mode 100644 esphome/components/filter_lifetime/sensor.py create mode 100644 tests/components/filter_lifetime/__init__.py create mode 100644 tests/components/filter_lifetime/common.yaml create mode 100644 tests/components/filter_lifetime/lambda_only.yaml create mode 100644 tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp8266-ard.yaml create mode 100644 tests/components/filter_lifetime/test.rp2040-ard.yaml create mode 100644 tests/components/filter_lifetime/test_filter_lifetime.cpp create mode 100644 tests/unit_tests/components/filter_lifetime/__init__.py create mode 100644 tests/unit_tests/components/filter_lifetime/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fc14cb33d09..2c026b92f5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core +esphome/components/filter_lifetime/* @tuct esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/friedrich/* @rwrozelle diff --git a/esphome/components/filter_lifetime/__init__.py b/esphome/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..99a67538bf2 --- /dev/null +++ b/esphome/components/filter_lifetime/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tuct"] +DOMAIN = "filter_lifetime" diff --git a/esphome/components/filter_lifetime/filter_lifetime.cpp b/esphome/components/filter_lifetime/filter_lifetime.cpp new file mode 100644 index 00000000000..47059a7f7a2 --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.cpp @@ -0,0 +1,112 @@ +#include "filter_lifetime.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::filter_lifetime { + +ESPHOME_LOG_TAG(TAG, "filter_lifetime"); + +static constexpr uint32_t SECONDS_PER_HOUR = 3600; +static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f; + +void FilterLifetime::setup() { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->runtime_seconds_); + this->last_update_ = millis(); + this->publish_values_(); +} + +void FilterLifetime::update() { + const uint32_t now = App.get_loop_component_start_time(); + const uint32_t elapsed_ms = now - this->last_update_; + this->last_update_ = now; + + if (this->get_is_on_()) { + float speed = this->get_current_speed_(); + if (std::isnan(speed)) + speed = 0.0f; + speed = std::clamp(speed, 0.0f, 100.0f); + const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR; + this->carry_ms_ += static_cast(elapsed_ms * (speed / 100.0f)); + this->runtime_seconds_ += this->carry_ms_ / 1000; + this->carry_ms_ %= 1000; + // Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266. + if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours) + this->pref_.save(&this->runtime_seconds_); + ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_); + } + this->publish_values_(); +} + +void FilterLifetime::publish_values_() { + const float runtime_minutes = this->runtime_seconds_ / 60.0f; + const float max_minutes = this->max_lifetime_minutes_; + const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes); + this->publish_state(100.0f * remaining_minutes / max_minutes); + if (this->runtime_hours_sensor_ != nullptr) + this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f); + if (this->remaining_days_sensor_ != nullptr) + this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY); +} + +void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); } + +void FilterLifetime::reset_filter() { + this->runtime_seconds_ = 0; + this->carry_ms_ = 0; + this->pref_.save(&this->runtime_seconds_); + this->publish_values_(); + ESP_LOGI(TAG, "Filter lifetime reset"); +} + +bool FilterLifetime::get_is_on_() { +#ifdef USE_BINARY_SENSOR + // Reads false until the sensor first publishes, so nothing accumulates before then. + if (this->is_on_sensor_ != nullptr) + return this->is_on_sensor_->state; +#endif + if (this->is_on_lambda_ != nullptr) + return this->is_on_lambda_(); + return true; +} + +float FilterLifetime::get_current_speed_() { + if (this->current_speed_sensor_ != nullptr) + return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0 + if (this->current_speed_lambda_ != nullptr) + return this->current_speed_lambda_(); + return 100.0f; +} + +void FilterLifetime::dump_config() { + LOG_SENSOR("", "Filter Lifetime", this); + ESP_LOGCONFIG(TAG, + " Max lifetime: %" PRIu32 " min (%.1f days)\n" + " Runtime: %.1f h", + this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY, + this->runtime_seconds_ / 3600.0f); +#ifdef USE_BINARY_SENSOR + if (this->is_on_sensor_ != nullptr) { + LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " Is-on: %s", + this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always")); + } + if (this->current_speed_sensor_ != nullptr) { + LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_); + } else { + ESP_LOGCONFIG(TAG, " Speed: %s", + this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%")); + } + LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_); + LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/filter_lifetime.h b/esphome/components/filter_lifetime/filter_lifetime.h new file mode 100644 index 00000000000..e6f40d78f5b --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::filter_lifetime { + +class FilterLifetime : public sensor::Sensor, public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + void on_shutdown() override; + void reset_filter(); + + void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; } + void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; } +#ifdef USE_BINARY_SENSOR + void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; } +#endif + void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; } + void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; } + void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; } + void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; } + + protected: + bool get_is_on_(); + float get_current_speed_(); + void publish_values_(); + + bool (*is_on_lambda_)(){nullptr}; + float (*current_speed_lambda_)(){nullptr}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *is_on_sensor_{nullptr}; +#endif + sensor::Sensor *current_speed_sensor_{nullptr}; + sensor::Sensor *runtime_hours_sensor_{nullptr}; + sensor::Sensor *remaining_days_sensor_{nullptr}; + ESPPreferenceObject pref_; + uint32_t max_lifetime_minutes_{0}; + uint32_t last_update_{0}; + uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted + uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second +}; + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py new file mode 100644 index 00000000000..58af033f329 --- /dev/null +++ b/esphome/components/filter_lifetime/sensor.py @@ -0,0 +1,94 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DURATION, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_MAX_LIFETIME = "max_lifetime" +CONF_IS_ON = "is_on" +CONF_IS_ON_SENSOR = "is_on_sensor" +CONF_CURRENT_SPEED = "current_speed" +CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor" +CONF_RUNTIME_HOURS = "runtime_hours" +CONF_REMAINING_DAYS = "remaining_days" +UNIT_DAY = "d" + +filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime") +FilterLifetime = filter_lifetime_ns.class_( + "FilterLifetime", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + FilterLifetime, + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.Required(CONF_MAX_LIFETIME): cv.All( + cv.positive_time_period_minutes, + cv.Range(min=cv.TimePeriod(minutes=1)), + ), + cv.Optional(CONF_IS_ON): cv.returning_lambda, + cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor), + cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda, + cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + accuracy_decimals=1, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:timer-outline", + ), + cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema( + unit_of_measurement=UNIT_DAY, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + icon="mdi:calendar-clock", + ), + } + ) + .extend(cv.polling_component_schema("60s")), + cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR), + cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME])) + + if (is_on := config.get(CONF_IS_ON)) is not None: + cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) + elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: + cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + + if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: + cg.add( + var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) + ) + elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: + cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) + await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor) + + +automation.register_apply_action( + "filter_lifetime.reset_filter", + cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}), + automation.ApplyCall("reset_filter()"), +) diff --git a/tests/components/filter_lifetime/__init__.py b/tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..70b7f08adf3 --- /dev/null +++ b/tests/components/filter_lifetime/__init__.py @@ -0,0 +1,12 @@ +from esphome.core import CORE +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: + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"] diff --git a/tests/components/filter_lifetime/common.yaml b/tests/components/filter_lifetime/common.yaml new file mode 100644 index 00000000000..1cf8a3e0b18 --- /dev/null +++ b/tests/components/filter_lifetime/common.yaml @@ -0,0 +1,62 @@ +binary_sensor: + - platform: template + name: "Fan Running" + id: fan_running + +sensor: + - platform: template + name: "Fan Speed Percent" + id: fan_speed_percent + unit_of_measurement: "%" + + # Test with lambdas + - platform: filter_lifetime + name: "Filter Lifetime Lambda" + id: filter_lifetime_lambda + max_lifetime: 365d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 50.0;' + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Lambda" + remaining_days: + name: "Filter Remaining Days Lambda" + + # Test with sensor references + - platform: filter_lifetime + name: "Filter Lifetime Sensor" + id: filter_lifetime_sensor + max_lifetime: 365d + is_on_sensor: fan_running + current_speed_sensor: fan_speed_percent + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Sensor" + remaining_days: + name: "Filter Remaining Days Sensor" + + # Test with defaults (always on at 100%) + - platform: filter_lifetime + name: "Filter Lifetime Default" + id: filter_lifetime_default + max_lifetime: 180d + update_interval: 60s + +button: + - platform: template + name: "Reset Filter Lambda" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_lambda + + - platform: template + name: "Reset Filter Sensor" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_sensor + + - platform: template + name: "Reset Filter Default" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_default diff --git a/tests/components/filter_lifetime/lambda_only.yaml b/tests/components/filter_lifetime/lambda_only.yaml new file mode 100644 index 00000000000..9affd2d469f --- /dev/null +++ b/tests/components/filter_lifetime/lambda_only.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: filter_lifetime + name: "Filter Lifetime Lambda Only" + max_lifetime: 180d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 75.0f;' + remaining_days: + name: "Filter Remaining Days Lambda Only" diff --git a/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml new file mode 100644 index 00000000000..aa0d3c679ff --- /dev/null +++ b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include lambda_only.yaml diff --git a/tests/components/filter_lifetime/test.esp32-idf.yaml b/tests/components/filter_lifetime/test.esp32-idf.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.esp8266-ard.yaml b/tests/components/filter_lifetime/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.rp2040-ard.yaml b/tests/components/filter_lifetime/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp new file mode 100644 index 00000000000..a4c89edd856 --- /dev/null +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -0,0 +1,224 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/filter_lifetime/filter_lifetime.h" +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::filter_lifetime::testing { + +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + +class TestableFilterLifetime : public FilterLifetime { + public: + using FilterLifetime::last_update_; + using FilterLifetime::runtime_seconds_; +}; + +static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static constexpr uint32_t MAX_MINUTES = 600; // 10 hours +static constexpr uint32_t FILTER_HASH = 0x104001; + +class FilterLifetimeTest : public ::testing::Test { + protected: + TestableFilterLifetime filter_; + sensor::Sensor runtime_hours_; + sensor::Sensor remaining_days_; + float last_state_{NAN}; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); + host::setup_preferences(); + host::host_preferences->reset(); + g_is_on = true; + g_speed = 100.0f; + this->attach(this->filter_); + this->filter_.add_on_state_callback([this](float value) { + this->last_state_ = value; + ++this->publications_; + }); + } + + void TearDown() override { + host::host_preferences->reset(); + App.~Application(); + } + + void attach(FilterLifetime &filter) { + EntityIdentity::configure(filter, "Filter", FILTER_HASH); + filter.set_max_lifetime(MAX_MINUTES); + filter.set_is_on_lambda([]() { return g_is_on; }); + filter.set_current_speed_lambda([]() { return g_speed; }); + filter.set_runtime_hours_sensor(&this->runtime_hours_); + filter.set_remaining_days_sensor(&this->remaining_days_); + } + + // Run one update() as if elapsed_ms passed since the previous one + void elapse(uint32_t elapsed_ms) { + this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms; + this->filter_.update(); + } + + bool stored(uint32_t &value) { return this->filter_.make_entity_preference().load(&value); } + + static float expected_percent(uint32_t runtime_seconds) { + return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES; + } +}; + +TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) { + this->filter_.setup(); + EXPECT_EQ(this->publications_, 1u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) { + this->filter_.setup(); + g_speed = 50.0f; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 30u); + EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30)); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) { + this->filter_.setup(); + g_speed = 25.0f; + for (int i = 0; i < 3; i++) + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) { + this->filter_.setup(); + g_is_on = false; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_EQ(this->publications_, 2u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); +} + +TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) { + this->filter_.setup(); + g_speed = 250.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = NAN; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = -5.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) { + sensor::Sensor speed; + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.set_current_speed_sensor(&speed); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + speed.state = 50.0f; + this->elapse(2000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) { + binary_sensor::BinarySensor running; + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_is_on_sensor(&running); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + running.publish_state(true); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + running.publish_state(false); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) { + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) { + this->filter_.setup(); + uint32_t value = 0; + this->elapse(3599000); + EXPECT_FALSE(this->stored(value)); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); +} + +TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) { + this->filter_.setup(); + this->elapse(1000); + this->filter_.on_shutdown(); + uint32_t value = 0; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 1u); +} + +TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) { + this->filter_.setup(); + this->elapse(1234000); + this->filter_.on_shutdown(); + + TestableFilterLifetime restored; + this->attach(restored); + restored.setup(); + EXPECT_EQ(restored.runtime_seconds_, 1234u); + EXPECT_FLOAT_EQ(restored.state, expected_percent(1234)); +} + +TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) { + this->filter_.setup(); + this->elapse(5000); + this->filter_.reset_filter(); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + uint32_t value = 1; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 0u); +} + +TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) { + this->filter_.set_max_lifetime(1); + this->filter_.setup(); + this->elapse(120000); + EXPECT_FLOAT_EQ(this->last_state_, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f); +} + +} // namespace esphome::filter_lifetime::testing + +#endif // USE_HOST diff --git a/tests/unit_tests/components/filter_lifetime/__init__.py b/tests/unit_tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/filter_lifetime/test_sensor.py b/tests/unit_tests/components/filter_lifetime/test_sensor.py new file mode 100644 index 00000000000..7e8d85fcda6 --- /dev/null +++ b/tests/unit_tests/components/filter_lifetime/test_sensor.py @@ -0,0 +1,99 @@ +"""Tests for the filter_lifetime configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.filter_lifetime import sensor as filter_lifetime +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_DURATION, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.core import Lambda +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config}) + + +def test_max_lifetime_is_stored_in_minutes() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config["max_lifetime"].total_minutes == 180 * 24 * 60 + + +def test_max_lifetime_is_required() -> None: + with pytest.raises(cv.Invalid, match="required key not provided"): + _validate({}) + + +@pytest.mark.parametrize("value", ["0min", "30s", 6]) +def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None: + with pytest.raises(cv.Invalid): + _validate({"max_lifetime": value}) + + +def test_defaults() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config[CONF_UPDATE_INTERVAL].total_seconds == 60 + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT + assert config[CONF_ACCURACY_DECIMALS] == 2 + assert "is_on" not in config + assert "current_speed" not in config + + +@pytest.mark.parametrize( + ("lambda_key", "sensor_key"), + [("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")], +) +def test_lambda_and_sensor_are_mutually_exclusive( + lambda_key: str, sensor_key: str +) -> None: + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + _validate( + { + "max_lifetime": "180d", + lambda_key: Lambda("return 1;"), + sensor_key: "some_id", + } + ) + + +def test_lambda_inputs_accepted() -> None: + config = _validate( + { + "max_lifetime": "180d", + "is_on": Lambda("return true;"), + "current_speed": Lambda("return 50.0f;"), + } + ) + assert config["is_on"].value == "return true;" + assert config["current_speed"].value == "return 50.0f;" + + +def test_sub_sensor_defaults() -> None: + config = _validate( + { + "max_lifetime": "180d", + "runtime_hours": {"name": "Runtime"}, + "remaining_days": {"name": "Remaining"}, + } + ) + runtime = config["runtime_hours"] + assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR + assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + remaining = config["remaining_days"] + assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY + assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert remaining[CONF_ACCURACY_DECIMALS] == 0 From 95766ed3ad043f45eabf6e92b7847769427f4b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Oct 2026 20:07:32 +0200 Subject: [PATCH 37/66] [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icm20689/__init__.py | 13 ++ esphome/components/icm20689/icm20689.cpp | 126 ++++++++++++++++++ esphome/components/icm20689/icm20689.h | 66 +++++++++ esphome/components/icm20689/motion.py | 56 ++++++++ esphome/components/icm20689/sensor.py | 42 ++++++ tests/components/icm20689/common.yaml | 73 ++++++++++ tests/components/icm20689/test.esp32-idf.yaml | 3 + .../components/icm20689/test.esp8266-ard.yaml | 3 + .../components/icm20689/test.rp2040-ard.yaml | 3 + 10 files changed, 386 insertions(+) create mode 100644 esphome/components/icm20689/__init__.py create mode 100644 esphome/components/icm20689/icm20689.cpp create mode 100644 esphome/components/icm20689/icm20689.h create mode 100644 esphome/components/icm20689/motion.py create mode 100644 esphome/components/icm20689/sensor.py create mode 100644 tests/components/icm20689/common.yaml create mode 100644 tests/components/icm20689/test.esp32-idf.yaml create mode 100644 tests/components/icm20689/test.esp8266-ard.yaml create mode 100644 tests/components/icm20689/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c026b92f5b..fc53a28c0b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 +esphome/components/icm20689/* @lboue esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core diff --git a/esphome/components/icm20689/__init__.py b/esphome/components/icm20689/__init__.py new file mode 100644 index 00000000000..b57221814e8 --- /dev/null +++ b/esphome/components/icm20689/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@lboue"] +DOMAIN = "icm20689" + +CONF_ICM20689_ID = "icm20689_id" +# C++ namespace / class +icm20689_ns = cg.esphome_ns.namespace("icm20689") +ICM20689Component = icm20689_ns.class_( + "ICM20689Component", MotionComponent, spi.SPIDevice +) diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp new file mode 100644 index 00000000000..7331a3fe4f6 --- /dev/null +++ b/esphome/components/icm20689/icm20689.cpp @@ -0,0 +1,126 @@ +#include "icm20689.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::icm20689 { + +ESPHOME_LOG_TAG(TAG, "icm20689"); + +namespace { + +// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range. +constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; } + +constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; } + +} // namespace + +void ICM20689Component::write_register_(uint8_t reg, uint8_t value) { + this->enable(); + this->write_byte(reg & 0x7F); + this->write_byte(value); + this->disable(); +} + +void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) { + this->enable(); + this->write_byte(reg | ICM20689_READ_BIT); + this->read_array(data, len); + this->disable(); +} + +void ICM20689Component::setup() { + MotionComponent::setup(); + + this->spi_setup(); + + if (!this->spi_is_ready()) { + ESP_LOGE(TAG, "SPI device is not ready"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Datasheet 4.20: a soft reset must be the first operation after power-up, + // before any register initialization. WHO_AM_I is only valid afterwards. + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET); + delay(100); // NOLINT + + // The chip comes out of reset in sleep mode: wake it and select the + // auto-selected best clock source (required for full gyro performance). + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO); + + // USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake. + // Datasheet 6.1: disable the I2C interface when using SPI. + this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS); + + uint8_t who_am_i = 0; + this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1); + + if (who_am_i != ICM20689_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Allow the sensor to settle after waking from sleep. + delay(50); // NOLINT + + // Configure the accelerometer and gyroscope full-scale ranges. + this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast(this->accel_range_) << 3); + this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast(this->gyro_range_) << 3); +} + +void ICM20689Component::dump_config() { + ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); + LOG_PIN(" CS Pin: ", this->cs_); + + if (this->is_failed()) { + ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); + ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); + + MotionComponent::dump_config(); +} + +bool ICM20689Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + const uint8_t accel_range = static_cast(this->accel_range_); + const uint8_t gyro_range = static_cast(this->gyro_range_); + + // Read one contiguous sample: + // 0..5 accelerometer X/Y/Z + // 6..7 temperature + // 8..13 gyroscope X/Y/Z + uint8_t raw_data[14] = {}; + this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data)); + + const float accel_factor = accel_scale(accel_range); + + data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; + data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; + data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + + const float gyro_factor = gyro_scale(gyro_range); + + data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + + if (!this->temperature_callback_.empty()) { + // Datasheet formula: temperature (°C) = raw / 326.8 + 25. + const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + this->temperature_callback_.call(temperature); + } + + return true; +} + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/icm20689.h b/esphome/components/icm20689/icm20689.h new file mode 100644 index 00000000000..08ed9d03eff --- /dev/null +++ b/esphome/components/icm20689/icm20689.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::icm20689 { + +// Register map +static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75; +static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B; +static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A; +static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B; +static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C; +static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B; +static constexpr uint8_t ICM20689_READ_BIT = 0x80; +static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98; +static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20) +static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock) +static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4 + +// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG +enum ICM20689AccelRange : uint8_t { + ICM20689_ACCEL_RANGE_2G = 0x00, + ICM20689_ACCEL_RANGE_4G = 0x01, + ICM20689_ACCEL_RANGE_8G = 0x02, + ICM20689_ACCEL_RANGE_16G = 0x03, +}; + +// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG +enum ICM20689GyroRange : uint8_t { + ICM20689_GYRO_RANGE_250 = 0x00, + ICM20689_GYRO_RANGE_500 = 0x01, + ICM20689_GYRO_RANGE_1000 = 0x02, + ICM20689_GYRO_RANGE_2000 = 0x03, +}; + +class ICM20689Component final : public motion::MotionComponent, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; } + void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; } + + template void add_temperature_listener(F &&callback) { + this->temperature_callback_.add(std::forward(callback)); + } + + protected: + bool update_data(motion::MotionData &data) override; + + void write_register_(uint8_t reg, uint8_t value); + // Reads len bytes starting at reg in one CS-low transaction. + void read_registers_(uint8_t reg, uint8_t *data, size_t len); + + ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G}; + ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py new file mode 100644 index 00000000000..7673dcf4cf4 --- /dev/null +++ b/esphome/components/icm20689/motion.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ICM20689Component, icm20689_ns + +DEPENDENCIES = ["spi"] + +# Enum proxies (must match the C++ enum values exactly) +ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, + "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, + "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, + "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, +} + +ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") +GYRO_RANGE_OPTIONS = { + "250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250, + "500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500, + "1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000, + "2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(ICM20689Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + } + ) + .extend( + spi.spi_device_schema( + cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz" + ) + ) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await spi.register_spi_device(var, config) + + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) diff --git a/esphome/components/icm20689/sensor.py b/esphome/components/icm20689/sensor.py new file mode 100644 index 00000000000..7cd5dedba07 --- /dev/null +++ b/esphome/components/icm20689/sensor.py @@ -0,0 +1,42 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_ICM20689_ID, ICM20689Component + +AUTO_LOAD = ["icm20689"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_ICM20689_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/icm20689/common.yaml b/tests/components/icm20689/common.yaml new file mode 100644 index 00000000000..522a0a2bbf7 --- /dev/null +++ b/tests/components/icm20689/common.yaml @@ -0,0 +1,73 @@ +sensor: + - platform: icm20689 + type: temperature + name: "ICM20689 Temperature" + + - platform: motion + motion_id: icm20689_motion + type: acceleration_x + name: "ICM20689 Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + motion_id: icm20689_motion + type: acceleration_y + name: "ICM20689 Accel Y" + accuracy_decimals: 4 + - platform: motion + motion_id: icm20689_motion + type: acceleration_z + name: "ICM20689 Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + motion_id: icm20689_motion + type: gyroscope_x + name: "ICM20689 Gyro X" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_y + name: "ICM20689 Gyro Y" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_z + name: "ICM20689 Gyro Z" + + - platform: motion + motion_id: icm20689_motion + type: angular_rate_x + name: "ICM20689 Angular Rate X" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_y + name: "ICM20689 Angular Rate Y" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_z + name: "ICM20689 Angular Rate Z" + + - platform: motion + motion_id: icm20689_motion + type: pitch + name: "ICM20689 Pitch" + - platform: motion + motion_id: icm20689_motion + type: roll + name: "ICM20689 Roll" + +motion: + - platform: icm20689 + id: icm20689_motion + cs_pin: GPIO5 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + # Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 500DPS + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/icm20689/test.esp32-idf.yaml b/tests/components/icm20689/test.esp32-idf.yaml new file mode 100644 index 00000000000..0c9ea68ef00 --- /dev/null +++ b/tests/components/icm20689/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.esp8266-ard.yaml b/tests/components/icm20689/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ab217e25fb8 --- /dev/null +++ b/tests/components/icm20689/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.rp2040-ard.yaml b/tests/components/icm20689/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fc5a73cf62d --- /dev/null +++ b/tests/components/icm20689/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + icm20689: !include common.yaml From 40271fb59768552597151e14b5f2a340a810e3a8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 14 Oct 2026 05:10:36 +1100 Subject: [PATCH 38/66] [haier_tundra] New Climate IR component (#20469) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/haier_tundra/__init__.py | 2 + .../haier_tundra/button/__init__.py | 43 +++ .../button/haier_tundra_button.cpp | 18 ++ .../haier_tundra/button/haier_tundra_button.h | 25 ++ esphome/components/haier_tundra/climate.py | 51 ++++ .../components/haier_tundra/haier_tundra.cpp | 260 ++++++++++++++++++ .../components/haier_tundra/haier_tundra.h | 139 ++++++++++ .../haier_tundra/switch/__init__.py | 60 ++++ .../switch/haier_tundra_switch.cpp | 43 +++ .../haier_tundra/switch/haier_tundra_switch.h | 28 ++ tests/components/haier_tundra/common.yaml | 45 +++ .../haier_tundra/test.esp32-idf.yaml | 3 + .../haier_tundra/test.esp8266-ard.yaml | 3 + 14 files changed, 723 insertions(+) create mode 100644 esphome/components/haier_tundra/__init__.py create mode 100644 esphome/components/haier_tundra/button/__init__.py create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.cpp create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.h create mode 100644 esphome/components/haier_tundra/climate.py create mode 100644 esphome/components/haier_tundra/haier_tundra.cpp create mode 100644 esphome/components/haier_tundra/haier_tundra.h create mode 100644 esphome/components/haier_tundra/switch/__init__.py create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.cpp create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.h create mode 100644 tests/components/haier_tundra/common.yaml create mode 100644 tests/components/haier_tundra/test.esp32-idf.yaml create mode 100644 tests/components/haier_tundra/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fc53a28c0b3..a770810490b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,9 @@ esphome/components/haier/button/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn +esphome/components/haier_tundra/* @fauxpark +esphome/components/haier_tundra/button/* @fauxpark +esphome/components/haier_tundra/switch/* @fauxpark esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_tundra/__init__.py b/esphome/components/haier_tundra/__init__.py new file mode 100644 index 00000000000..d74ae6947f2 --- /dev/null +++ b/esphome/components/haier_tundra/__init__.py @@ -0,0 +1,2 @@ +DOMAIN = "haier_tundra" +CODEOWNERS = ["@fauxpark"] diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button/__init__.py new file mode 100644 index 00000000000..2e919b80099 --- /dev/null +++ b/esphome/components/haier_tundra/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraButton = haier_tundra_ns.class_( + "HaierTundraButton", button.Button, cg.Component +) +ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True) + +ICON_LED_OUTLINE = "mdi:led-outline" +ICON_SPRAY_BOTTLE = "mdi:spray-bottle" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_SELF_CLEAN = "self_clean" + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_LIGHT): button.button_schema( + HaierTundraButton, icon=ICON_LED_OUTLINE + ), + cv.Optional(CONF_SELF_CLEAN): button.button_schema( + HaierTundraButton, icon=ICON_SPRAY_BOTTLE + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + for key, kind in ( + (CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT), + (CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN), + ): + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, hub, kind) + await cg.register_component(var, conf) diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.cpp b/esphome/components/haier_tundra/button/haier_tundra_button.cpp new file mode 100644 index 00000000000..39246530bc3 --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.cpp @@ -0,0 +1,18 @@ +#include "haier_tundra_button.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.button"); + +void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); } + +void HaierTundraButton::press_action() { + if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) { + this->parent_->toggle_light(); + } else { + this->parent_->start_self_clean(); + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.h b/esphome/components/haier_tundra/button/haier_tundra_button.h new file mode 100644 index 00000000000..c4888a4616a --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/button/button.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class ButtonKind : uint8_t { + BUTTON_KIND_LIGHT, + BUTTON_KIND_SELF_CLEAN, +}; + +class HaierTundraButton final : public button::Button, public Component, public Parented { + public: + HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {} + void dump_config() override; + + protected: + void press_action() override; + + ButtonKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py new file mode 100644 index 00000000000..c992d14bfed --- /dev/null +++ b/esphome/components/haier_tundra/climate.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra") +HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) + +CONF_UNIT = "unit" +Unit = haier_tundra_ns.enum("Unit") +UNITS = { + "a": Unit.HAIER_HEADER_UNIT_A, + "b": Unit.HAIER_HEADER_UNIT_B, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( + { + cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True), + } +) + + +def _final_validate_unit(config: ConfigType) -> None: + # Each transmitter may drive at most one unit A and one unit B + climates = fv.full_config.get()["climate"] + seen = set() + + for climate in climates: + if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra": + unit_tx_pair = ( + climate[CONF_UNIT], + climate[remote_base.CONF_TRANSMITTER_ID], + ) + + if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]: + raise cv.Invalid( + "Only one of each unit is allowed per transmitter", path=[CONF_UNIT] + ) + seen.add(unit_tx_pair) + + +FINAL_VALIDATE_SCHEMA = _final_validate_unit + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("haier") + await climate_ir.new_climate_ir(config, config[CONF_UNIT]) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp new file mode 100644 index 00000000000..7a0b88d3aac --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -0,0 +1,260 @@ +#include "haier_tundra.h" + +namespace esphome::haier_tundra { + +// Number of bytes in decoded packet sans checksum +static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13; + +void HaierTundra::transmit_state() { + // Button value does not seem to matter too much except + // when toggling light, as it has no bit flag of its own + // So in the default case we can simply set it to POWER + this->transmit_(HAIER_BUTTON_POWER); +} + +void HaierTundra::transmit_(uint8_t button) { + // Force Quiet and Turbo off if we're not in Cool or Heat mode + if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + + this->quiet_mode_ = false; + this->turbo_mode_ = false; + } + + remote_base::HaierData remote_state{{ + this->unit_, + static_cast(this->temperature_() << 4 | this->vertical_swing_()), + static_cast(this->horizontal_swing_() << 5), + static_cast(this->health_() << 1), + static_cast(this->power_() << 6), + static_cast(this->fan_speed_() << 5), + static_cast(this->quiet_() << 7 | this->turbo_() << 6), + static_cast(this->operation_mode_() << 5), + 0x00, + 0x00, + static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), + 0x00, + button, + }}; + + auto transmit = this->transmitter_->transmit(); + remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + transmit.perform(); +} + +uint8_t HaierTundra::temperature_() { + return (uint8_t) roundf(clamp(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN; +} + +uint8_t HaierTundra::vertical_swing_() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + return HAIER_SWING_V_OFF; + } + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_V_AUTO; + default: + if (this->mode == climate::CLIMATE_MODE_HEAT) { + return HAIER_SWING_V_1BOT; + } + return HAIER_SWING_V_1TOP; // Cool/Dry/Auto + } +} + +uint8_t HaierTundra::horizontal_swing_() { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_HORIZONTAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_H_AUTO; + default: + return HAIER_SWING_H_MIDDLE; + } +} + +uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; } + +uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; } + +uint8_t HaierTundra::fan_speed_() { + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { + case climate::CLIMATE_FAN_LOW: + return HAIER_FAN_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return HAIER_FAN_MED; + case climate::CLIMATE_FAN_HIGH: + return HAIER_FAN_HIGH; + case climate::CLIMATE_FAN_AUTO: + default: + return HAIER_FAN_AUTO; + } +} + +uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; } + +uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; } + +uint8_t HaierTundra::operation_mode_() { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + return HAIER_MODE_COOL; + case climate::CLIMATE_MODE_DRY: + return HAIER_MODE_DRY; + case climate::CLIMATE_MODE_HEAT: + return HAIER_MODE_HEAT; + case climate::CLIMATE_MODE_FAN_ONLY: + return HAIER_MODE_FAN; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return HAIER_MODE_AUTO; + } +} + +bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) { + auto haier = remote_base::HaierProtocol().decode(data); + if (haier.has_value()) { + return this->on_haier_(*haier); + } + return false; +} + +bool HaierTundra::on_haier_(const remote_base::HaierData &haier) { + // Verify frame length and header + if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) { + return false; + } + + this->target_temperature = this->get_temperature_(haier.data[1] >> 4); + this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5); + this->health_mode_ = haier.data[3] & 0x2; + this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5); + this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5); + this->quiet_mode_ = haier.data[6] & 0x80; + this->turbo_mode_ = haier.data[6] & 0x40; + +#ifdef USE_SWITCH + if (this->health_mode_switch_ != nullptr) { + this->health_mode_switch_->publish_state(this->health_mode_); + } + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(this->quiet_mode_); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(this->turbo_mode_); + } +#endif + + this->publish_state(); + + return true; +} + +float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; } + +climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) { + if (swing_v == HAIER_SWING_V_AUTO) { + if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_BOTH; + } + + return climate::CLIMATE_SWING_VERTICAL; + } else if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_HORIZONTAL; + } + + return climate::CLIMATE_SWING_OFF; +} + +climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) { + if (!power) { + return climate::CLIMATE_MODE_OFF; + } + + switch (mode) { + case HAIER_MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case HAIER_MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case HAIER_MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case HAIER_MODE_FAN: + return climate::CLIMATE_MODE_FAN_ONLY; + case HAIER_MODE_AUTO: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) { + switch (fan) { + case HAIER_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case HAIER_FAN_MED: + return climate::CLIMATE_FAN_MEDIUM; + case HAIER_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + case HAIER_FAN_AUTO: + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +void HaierTundra::set_health_mode(bool health, bool transmit) { + this->health_mode_ = health; + + if (transmit && this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_HEALTH); + } +} + +void HaierTundra::set_quiet_mode(bool quiet, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (quiet) { +#ifdef USE_SWITCH + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + this->turbo_mode_ = false; + } + + this->quiet_mode_ = quiet; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::set_turbo_mode(bool turbo, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (turbo) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } +#endif + this->quiet_mode_ = false; + } + + this->turbo_mode_ = turbo; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::toggle_light() { + if (this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_LIGHT); + } +} + +void HaierTundra::start_self_clean() { + // Can be started when off + this->transmit_(HAIER_BUTTON_SELF_CLEAN); +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/haier_tundra.h b/esphome/components/haier_tundra/haier_tundra.h new file mode 100644 index 00000000000..b2bb1e51ce9 --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.h @@ -0,0 +1,139 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/haier_protocol.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +namespace esphome::haier_tundra { + +// Byte 0 - Header value (Unit ID) +enum Unit : uint8_t { + HAIER_HEADER_UNIT_B = 0x59, + HAIER_HEADER_UNIT_A = 0xA6, +}; + +// Byte 1[7:4] - Temperature (˚C) 0x0..0xE +static constexpr uint8_t HAIER_TEMP_MIN = 16; +static constexpr uint8_t HAIER_TEMP_MAX = 30; + +// Byte 1[3:0] - Vertical Swing +static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position +static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ] +static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode) +static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //] +static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only) +static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC; + +// Byte 2[7:5] - Horizontal Swing +static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ] +static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ] +static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \] +static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\] + +// Byte 3[7:5] - Timer Mode +static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0; +static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF +static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON +static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF +static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF + +// Byte 3[1] - Health +// Byte 4[6] - Power + +// Byte 5[7:5] - Fan Speed +static constexpr uint8_t HAIER_FAN_HIGH = 0x1; +static constexpr uint8_t HAIER_FAN_MED = 0x2; +static constexpr uint8_t HAIER_FAN_LOW = 0x3; +static constexpr uint8_t HAIER_FAN_AUTO = 0x5; + +// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17 +// Byte 6[7] - Quiet Mode +// Byte 6[6] - Turbo Mode +// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B + +// Byte 7[7:5] - Mode +static constexpr uint8_t HAIER_MODE_AUTO = 0x0; +static constexpr uint8_t HAIER_MODE_COOL = 0x1; +static constexpr uint8_t HAIER_MODE_DRY = 0x2; +static constexpr uint8_t HAIER_MODE_HEAT = 0x4; +static constexpr uint8_t HAIER_MODE_FAN = 0x6; + +// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17 +// Byte 8[7] - Sleep Mode +// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B +// Byte 10[4] - Self Clean + +// Byte 12 - Button +static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00; +static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01; +static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02; +static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03; +static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04; +static constexpr uint8_t HAIER_BUTTON_POWER = 0x05; +static constexpr uint8_t HAIER_BUTTON_MODE = 0x06; +static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07; +static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08; +static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B; +static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10; +static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15; +static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19; + +class HaierTundra final : public climate_ir::ClimateIR { + public: + explicit HaierTundra(Unit unit) + : climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}), + unit_(unit) {} + +#ifdef USE_SWITCH + SUB_SWITCH(health_mode); + SUB_SWITCH(quiet_mode); + SUB_SWITCH(turbo_mode); +#endif + + void set_health_mode(bool health, bool transmit = true); + void set_quiet_mode(bool quiet, bool transmit = true); + void set_turbo_mode(bool turbo, bool transmit = true); + + void toggle_light(); + void start_self_clean(); + + protected: + // Transmit via IR the state of this climate controller. + void transmit_state() override; + + void transmit_(uint8_t button); + uint8_t temperature_(); + uint8_t vertical_swing_(); + uint8_t horizontal_swing_(); + uint8_t health_(); + uint8_t power_(); + uint8_t fan_speed_(); + uint8_t quiet_(); + uint8_t turbo_(); + uint8_t operation_mode_(); + + // Handle received IR Buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + bool on_haier_(const remote_base::HaierData &haier); + + float get_temperature_(uint8_t temp); + climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h); + climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode); + climate::ClimateFanMode get_fan_speed_(uint8_t fan); + + Unit unit_; + bool health_mode_{false}; + bool quiet_mode_{false}; + bool turbo_mode_{false}; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/__init__.py b/esphome/components/haier_tundra/switch/__init__.py new file mode 100644 index 00000000000..f62769c1350 --- /dev/null +++ b/esphome/components/haier_tundra/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraSwitch = haier_tundra_ns.class_( + "HaierTundraSwitch", switch.Switch, cg.Component +) +SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True) + +ICON_LEAF = "mdi:leaf" +ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right" +ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_HEALTH_MODE = "health_mode" +CONF_QUIET_MODE = "quiet_mode" +CONF_TURBO_MODE = "turbo_mode" + + +def _switch_schema(icon: str) -> cv.Schema: + return switch.switch_schema( + HaierTundraSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF), + cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT), + cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + switches = switch.sub_switches(config) + if sw := await switches( + CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH + ): + await cg.register_component(sw, config[CONF_HEALTH_MODE]) + if sw := await switches( + CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET + ): + await cg.register_component(sw, config[CONF_QUIET_MODE]) + if sw := await switches( + CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO + ): + await cg.register_component(sw, config[CONF_TURBO_MODE]) diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp new file mode 100644 index 00000000000..e24ada79b25 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp @@ -0,0 +1,43 @@ +#include "haier_tundra_switch.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.switch"); + +void HaierTundraSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + const bool state = initial.value(); + this->apply_(state, false); + this->publish_state(state); + } +} + +void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); } + +void HaierTundraSwitch::write_state(bool state) { + // Quiet and Turbo can only be turned on in Cool or Heat mode + if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL && + this->parent_->mode != climate::CLIMATE_MODE_HEAT) { + return; + } + this->apply_(state, true); + this->publish_state(state); +} + +void HaierTundraSwitch::apply_(bool state, bool transmit) { + switch (this->kind_) { + case SwitchKind::SWITCH_KIND_HEALTH: + this->parent_->set_health_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_QUIET: + this->parent_->set_quiet_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_TURBO: + this->parent_->set_turbo_mode(state, transmit); + break; + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.h b/esphome/components/haier_tundra/switch/haier_tundra_switch.h new file mode 100644 index 00000000000..d718b940a15 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class SwitchKind : uint8_t { + SWITCH_KIND_HEALTH, + SWITCH_KIND_QUIET, + SWITCH_KIND_TURBO, +}; + +class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented { + public: + HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {} + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; + void apply_(bool state, bool transmit); + + SwitchKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/tests/components/haier_tundra/common.yaml b/tests/components/haier_tundra/common.yaml new file mode 100644 index 00000000000..0388220cacd --- /dev/null +++ b/tests/components/haier_tundra/common.yaml @@ -0,0 +1,45 @@ +climate: + - platform: haier_tundra + name: Haier AC A + id: my_haier_ac_a + transmitter_id: xmitr + + - platform: haier_tundra + name: Haier AC B + id: my_haier_ac_b + transmitter_id: xmitr + unit: b + +switch: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + quiet_mode: + name: "Quiet Mode (Unit A)" + turbo_mode: + name: "Turbo Mode (Unit A)" + health_mode: + name: "Health Mode (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + quiet_mode: + name: "Quiet Mode (Unit B)" + turbo_mode: + name: "Turbo Mode (Unit B)" + health_mode: + name: "Health Mode (Unit B)" + +button: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + light: + name: "Toggle Light (Unit A)" + self_clean: + name: "Start Self Clean (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + light: + name: "Toggle Light (Unit B)" + self_clean: + name: "Start Self Clean (Unit B)" diff --git a/tests/components/haier_tundra/test.esp32-idf.yaml b/tests/components/haier_tundra/test.esp32-idf.yaml new file mode 100644 index 00000000000..b88eb95efe7 --- /dev/null +++ b/tests/components/haier_tundra/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + haier_tundra: !include common.yaml diff --git a/tests/components/haier_tundra/test.esp8266-ard.yaml b/tests/components/haier_tundra/test.esp8266-ard.yaml new file mode 100644 index 00000000000..888edbf140b --- /dev/null +++ b/tests/components/haier_tundra/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + haier_tundra: !include common.yaml From 5acbd44067417199f89c8c8d6abcbdd01de6b159 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:12:28 +0200 Subject: [PATCH 39/66] [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589) Co-authored-by: J. Nick Koston --- esphome/components/pcf8574/__init__.py | 6 + esphome/components/pcf8574/pcf8574.cpp | 33 ++-- esphome/components/pcf8574/pcf8574.h | 11 +- tests/components/pcf8574/common.yaml | 32 ++++ tests/components/pcf8574/test_pcf8574.cpp | 189 ++++++++++++++++++++ tests/unit_tests/components/test_pcf8574.py | 35 ++++ 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 tests/components/pcf8574/test_pcf8574.cpp create mode 100644 tests/unit_tests/components/test_pcf8574.py diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 6c9ed3cd98b..5ede39d7145 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_RESET, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" +# Keep in sync with PCF8574Component::reset_ in pcf8574.h. +DEFAULT_RESET = True CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) @@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if (reset := config[CONF_RESET]) != DEFAULT_RESET: + cg.add(var.set_reset(reset)) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 727e9a20265..ea1e894a530 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -12,8 +12,13 @@ void PCF8574Component::setup() { return; } - this->write_gpio_(); - this->read_gpio_(); + if (this->reset_) { + this->write_gpio_(); + this->read_gpio_(); + } else { + this->output_mask_ = this->input_mask_; + this->mode_mask_ = ~this->configured_input_pins_; + } if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); @@ -40,8 +45,9 @@ void PCF8574Component::loop() { void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" - " Is PCF8575: %s", - YESNO(this->pcf8575_)); + " Is PCF8575: %s\n" + " Reset: %s", + YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { @@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; - bool success; - uint8_t data[2]; - if (this->pcf8575_) { - success = this->read_bytes_raw(data, 2); - this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); - } else { - success = this->read_bytes_raw(data, 1); - this->input_mask_ = data[0]; - } - - if (!success) { + uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. + if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { this->status_set_warning(); return false; } + this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; this->status_clear_warning(); return true; } @@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() { float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } void PCF8574GPIOPin::setup() { pin_mode(flags_); } +void PCF8574GPIOPin::set_flags(gpio::Flags flags) { + this->flags_ = flags; + if (flags & gpio::FLAG_INPUT) + this->parent_->register_input_pin(this->pin_); +} void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 9879d6a47eb..465bbd7a918 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -16,6 +16,9 @@ class PCF8574Component final : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_reset(bool reset) { this->reset_ = reset; } + // Called by pin setters before any component's setup or GPIO writes. + void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks @@ -44,6 +47,11 @@ class PCF8574Component final : public Component, uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins + // (including undeclared pins) are treated as outputs seeded from their observed levels. + uint16_t configured_input_pins_{0x00}; + // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. + bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 InternalGPIOPin *interrupt_pin_{nullptr}; }; @@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin { void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + // Call after set_parent() and set_pin(): input flags register the pin with the parent. + void set_flags(gpio::Flags flags); gpio::Flags get_flags() const override { return this->flags_; } diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 8a26b930152..4613f4b8859 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -8,6 +8,16 @@ pcf8574: address: 0x22 pcf8575: false interrupt_pin: ${interrupt_pin} + reset: true + - id: pcf8574_preserved + i2c_id: i2c_bus + address: 0x23 + reset: false + - id: pcf8575_preserved + i2c_id: i2c_bus + address: 0x24 + pcf8575: true + reset: false binary_sensor: - platform: gpio @@ -18,6 +28,18 @@ binary_sensor: number: 1 mode: INPUT inverted: true + - platform: gpio + id: pcf8574_preserved_input + pin: + pcf8574: pcf8574_preserved + number: 1 + mode: INPUT + - platform: gpio + id: pcf8575_preserved_input + pin: + pcf8574: pcf8575_preserved + number: 15 + mode: INPUT output: - platform: gpio @@ -27,3 +49,13 @@ output: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcf8574_preserved_output + pin: + pcf8574: pcf8574_preserved + number: 0 + - platform: gpio + id: pcf8575_preserved_output + pin: + pcf8574: pcf8575_preserved + number: 8 diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp new file mode 100644 index 00000000000..3500069ba10 --- /dev/null +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -0,0 +1,189 @@ +#include + +#include "esphome/components/pcf8574/pcf8574.h" + +namespace esphome::pcf8574::testing { + +// Model quasi-bidirectional readback separately from the output latch. +class PortBus : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, 0x21); + if (read_count != 0) { + EXPECT_EQ(read_count, this->width); + this->reads++; + if (this->fail_read) + return i2c::ERROR_TIMEOUT; + uint16_t levels = this->latch & ~this->external_low; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = levels >> (8 * i); + } + if (write_count != 0) { + EXPECT_EQ(write_count, this->width); + this->writes++; + this->latch = write_buffer[0]; + if (write_count == 2) + this->latch |= uint16_t(write_buffer[1]) << 8; + } + return i2c::ERROR_OK; + } + + size_t width{1}; + uint16_t latch{0}; + uint16_t external_low{0}; + unsigned reads{0}; + bool fail_read{false}; + unsigned writes{0}; +}; + +class PCF8574ResetTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + this->bus_.width = GetParam() ? 2 : 1; + this->mask_ = GetParam() ? 0xFFFF : 0xFF; + this->high_pin_ = GetParam() ? 15 : 7; + this->bus_.latch = 0xA55A & this->mask_; + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(0x21); + this->component_.set_pcf8575(GetParam()); + } + + void configure_input_(uint8_t number) { + PCF8574GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 0); + } + + PortBus bus_; + PCF8574Component component_; + uint16_t mask_; + uint8_t high_pin_; +}; + +TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) { + this->configure_input_(1); + + this->component_.setup(); + ASSERT_EQ(this->bus_.writes, 1); + EXPECT_EQ(this->bus_.latch, this->mask_); + EXPECT_EQ(this->bus_.reads, 2); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 1); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, this->mask_ & ~1); +} + +TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) { + this->component_.set_reset(false); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 1); + EXPECT_EQ(this->bus_.latch, initial); + + // Consumers can write before calling their GPIO pin's setup(). + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + const uint16_t high_pin_mask = 1 << this->high_pin_; + this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0); + const uint16_t expected = (initial | 1) ^ high_pin_mask; + EXPECT_EQ(this->bus_.latch, expected); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, expected & ~1); + ASSERT_EQ(this->bus_.writes, 3); +} + +TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) { + const uint16_t inputs = (1 << 1) | (1 << this->high_pin_); + this->bus_.latch |= inputs; + const uint16_t initial = this->bus_.latch; + this->bus_.external_low = inputs; + + this->component_.set_reset(false); + this->configure_input_(1); + this->configure_input_(this->high_pin_); + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial | 1); + EXPECT_FALSE(this->component_.digital_read(1)); +} + +TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) { + this->component_.set_reset(false); + this->configure_input_(1); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + this->component_.pin_mode(1, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); +} + +TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) { + const uint16_t loaded_pin = 1 << this->high_pin_; + this->bus_.latch |= loaded_pin; + this->bus_.external_low = loaded_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin); +} + +TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) { + const uint16_t undeclared_pin = 1 << this->high_pin_; + this->bus_.latch |= undeclared_pin; + this->bus_.external_low = undeclared_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + this->bus_.external_low = 0; + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin); + EXPECT_FALSE(this->component_.digital_read(this->high_pin_)); +} + +TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) { + this->bus_.fail_read = true; + + this->component_.set_reset(false); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_failed()); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.writes, 0); +} + +INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool()); + +} // namespace esphome::pcf8574::testing diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py new file mode 100644 index 00000000000..43751815358 --- /dev/null +++ b/tests/unit_tests/components/test_pcf8574.py @@ -0,0 +1,35 @@ +"""PCF8574 reset configuration and code generation.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome.components import pcf8574 +from esphome.const import CONF_ID, CONF_RESET + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options, expected", + [ + ({}, True), + ({"reset": True}, True), + ({"reset": False}, False), + ], +) +async def test_reset_configuration(options: dict, expected: bool) -> None: + config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options}) + assert config[CONF_RESET] is expected + var = Mock() + with ( + patch.object(pcf8574.cg, "new_Pvariable", return_value=var), + patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.to_code(config) + + if expected: + var.set_reset.assert_not_called() + else: + var.set_reset.assert_called_once_with(False) From d27ec8ec4966634d59dee9002bd631e71f71d458 Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:13:26 +0200 Subject: [PATCH 40/66] [voice_assistant] Make TTS playback start timeout configurable (#18707) Co-authored-by: n-IA-hane --- .../components/voice_assistant/__init__.py | 16 ++++++- .../voice_assistant/voice_assistant.cpp | 2 +- .../voice_assistant/voice_assistant.h | 3 ++ .../voice_assistant/__init__.py | 0 .../voice_assistant/test_playback_timeout.py | 44 +++++++++++++++++++ .../voice_assistant/common-idf.yaml | 1 + tests/components/voice_assistant/common.yaml | 1 + .../components/voice_assistant/__init__.py | 0 .../test_tts_playback_timeout.py | 35 +++++++++++++++ 9 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/voice_assistant/__init__.py create mode 100644 tests/component_tests/voice_assistant/test_playback_timeout.py create mode 100644 tests/unit_tests/components/voice_assistant/__init__.py create mode 100644 tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d306947ec13..488e16a3839 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, core import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" +CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" +# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h. +TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000) CONF_ON_TIMER_STARTED = "on_timer_started" CONF_ON_TIMER_UPDATED = "on_timer_updated" @@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_CONVERSATION_TIMEOUT, default="300s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TTS_PLAYBACK_START_TIMEOUT, + default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT, + ): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range( min=0.0, min_included=False ), @@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN])) cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER])) cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT])) + if ( + timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT] + ) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT: + cg.add(var.set_tts_playback_start_timeout(timeout)) if CONF_ON_LISTENING in config: await automation.build_automation( diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 039410abdbf..1d66ca5950f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() { this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index ae5eee3e227..139a1c84c38 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -193,6 +193,7 @@ class VoiceAssistant final : public Component { void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; } void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; } void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; } + void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; } void reset_conversation_id(); Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; } @@ -318,6 +319,8 @@ class VoiceAssistant final : public Component { float volume_multiplier_; uint32_t conversation_timeout_; + // Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py. + uint32_t tts_playback_start_timeout_{2000}; Configuration config_{}; diff --git a/tests/component_tests/voice_assistant/__init__.py b/tests/component_tests/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/voice_assistant/test_playback_timeout.py b/tests/component_tests/voice_assistant/test_playback_timeout.py new file mode 100644 index 00000000000..cae93e18bf8 --- /dev/null +++ b/tests/component_tests/voice_assistant/test_playback_timeout.py @@ -0,0 +1,44 @@ +"""Test playback timeout C++ generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"]) +def test_playback_timeout_codegen( + timeout: str | None, + tmp_path: Path, + generate_main: Callable[[Path], str], +) -> None: + config = tmp_path / "voice_assistant.yaml" + config.write_text( + """esphome: + name: test-voice-assistant +esp32: + board: esp32dev + framework: + type: esp-idf +wifi: + ssid: test-network +api: +i2s_audio: + i2s_lrclk_pin: GPIO25 + i2s_bclk_pin: GPIO26 +microphone: + - platform: i2s_audio + id: test_mic + adc_type: external + i2s_din_pin: GPIO34 +voice_assistant: + microphone: test_mic +""" + + (f" tts_playback_start_timeout: {timeout}\n" if timeout else "") + ) + main = generate_main(config) + if timeout in (None, "2s", "2000ms"): + assert "set_tts_playback_start_timeout(" not in main + else: + milliseconds = 1 if timeout == "1ms" else 10000 + assert f"set_tts_playback_start_timeout({milliseconds});" in main diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 998ba90dd99..d08c76879e1 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -55,6 +55,7 @@ voice_assistant: speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 5a5791899e2..df1fa50eac9 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -36,6 +36,7 @@ voice_assistant: channels: 0 speaker: va_speaker_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/unit_tests/components/voice_assistant/__init__.py b/tests/unit_tests/components/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py new file mode 100644 index 00000000000..96f44a09e32 --- /dev/null +++ b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py @@ -0,0 +1,35 @@ +"""Validate Voice Assistant playback timeout configuration.""" + +import pytest + +from esphome.components import voice_assistant +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE + + +@pytest.mark.parametrize( + "value", + ["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}], +) +def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + + +@pytest.mark.parametrize( + "value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)] +) +def test_playback_timeout_accepts_positive_milliseconds( + value: str, milliseconds: int +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds + + +def test_playback_timeout_default_is_two_seconds() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({}) + assert config["tts_playback_start_timeout"].total_milliseconds == 2000 From 1d8423edd802f8cb8de213c1020e1b36b769e3dc Mon Sep 17 00:00:00 2001 From: cociweb Date: Tue, 13 Oct 2026 20:14:43 +0200 Subject: [PATCH 41/66] [es8388] Add initial option configuration for DAC output and ADC input mic (#16342) Co-authored-by: J. Nick Koston --- esphome/components/es8388/es8388.cpp | 40 +++++++++++------ esphome/components/es8388/es8388.h | 15 ++++++- esphome/components/es8388/select/__init__.py | 46 ++++++++++++++++++-- esphome/core/defines.h | 1 + tests/components/es8388/common.yaml | 4 ++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index b57949ccd0a..56ee1ac4a59 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -111,24 +111,36 @@ void ES8388::setup() { // Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09)); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT if (this->dac_output_select_ != nullptr) { - auto dac_power = this->get_dac_power(); - if (dac_power.has_value()) { - if (this->dac_output_select_->has_index(dac_power.value())) { - this->dac_output_select_->publish_state(dac_power.value()); - } else { - ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + if (this->dac_output_initial_.has_value()) { + const DacOutputLine line = this->dac_output_initial_.value(); + ES8388_ERROR_FAILED(this->set_dac_output(line)); + this->dac_output_select_->publish_state(static_cast(line)); + } else { + auto dac_power = this->get_dac_power(); + if (dac_power.has_value()) { + if (this->dac_output_select_->has_index(dac_power.value())) { + this->dac_output_select_->publish_state(dac_power.value()); + } else { + ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + } } } } if (this->adc_input_mic_select_ != nullptr) { - auto mic_input = this->get_mic_input(); - if (mic_input.has_value()) { - if (this->adc_input_mic_select_->has_index(mic_input.value())) { - this->adc_input_mic_select_->publish_state(mic_input.value()); - } else { - ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + if (this->adc_input_mic_initial_.has_value()) { + const AdcInputMicLine line = this->adc_input_mic_initial_.value(); + ES8388_ERROR_FAILED(this->set_adc_input_mic(line)); + this->adc_input_mic_select_->publish_state(static_cast(line)); + } else { + auto mic_input = this->get_mic_input(); + if (mic_input.has_value()) { + if (this->adc_input_mic_select_->has_index(mic_input.value())) { + this->adc_input_mic_select_->publish_state(mic_input.value()); + } else { + ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + } } } } @@ -138,7 +150,7 @@ void ES8388::setup() { void ES8388::dump_config() { ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:"); LOG_I2C_DEVICE(this); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index b01acb69c1e..b55538c94f8 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -5,7 +5,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT #include "esphome/components/select/select.h" #endif @@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t { }; class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) #endif @@ -68,11 +68,22 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I bool set_dac_output(DacOutputLine line); bool set_adc_input_mic(AdcInputMicLine line); +#ifdef USE_ES8388_SELECT + /// Line to apply in setup() instead of keeping what the codec reports. + void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; } + void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; } +#endif + protected: /// @brief Mutes or unmutes the DAC audio out /// @param mute_state True to mute, false to unmute /// @return True if successful and false otherwise bool set_mute_state_(bool mute_state); + +#ifdef USE_ES8388_SELECT + optional dac_output_initial_{}; + optional adc_input_mic_initial_{}; +#endif }; } // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index 62ddd6cb38a..f0e3f5b7c89 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -1,7 +1,11 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401 +from esphome.const import ( + CONF_INITIAL_OPTION, + ENTITY_CATEGORY_CONFIG, + ICON_CHIP, # noqa: F401 +) from esphome.types import ConfigType from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns @@ -9,6 +13,21 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" +DacOutputLine = es8388_ns.enum("DacOutputLine") +DAC_OUTPUT_OPTIONS = { + "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, + "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, + "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, +} +DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) +AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") +ADC_INPUT_MIC_OPTIONS = { + "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, + "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, + "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, +} +ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) + DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select) @@ -19,24 +38,45 @@ CONFIG_SCHEMA = cv.All( DacOutputSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + DAC_OUTPUT_OPTIONS, upper=True + ), + } ), cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema( ADCInputMicSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + ADC_INPUT_MIC_OPTIONS, upper=True + ), + } ), } ) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_ES8388_SELECT") hub = await cg.get_variable(config[CONF_ES8388_ID]) selects = select.sub_selects(config, parent=hub) await selects( - CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"] + CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES ) await selects( CONF_ADC_INPUT_MIC, hub.set_adc_input_mic_select, - options=["LINE1", "LINE2", "DIFFERENCE"], + options=ADC_INPUT_MIC_OPTION_NAMES, ) + if ( + initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_dac_output_initial(initial)) + if ( + initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_adc_input_mic_initial(initial)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d9b12fdddb1..08f1e31d6c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ES8388_SELECT #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED diff --git a/tests/components/es8388/common.yaml b/tests/components/es8388/common.yaml index cc7465ab935..aa08b9b0470 100644 --- a/tests/components/es8388/common.yaml +++ b/tests/components/es8388/common.yaml @@ -17,5 +17,9 @@ select: es8388_id: es8388_parent dac_output: name: "DAC Output" + id: es8388_test_dac_output_select + initial_option: BOTH adc_input_mic: name: "ADC Input MIC" + id: es8388_test_adc_input_mic_select + initial_option: LINE2 From 55261ae7e5df30e846d2e84bc1b6775b3eab49ad Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 20:17:25 +0200 Subject: [PATCH 42/66] [tmc2209] Add driver diagnostics and stall detection (#20618) Co-authored-by: Claude Opus 5.5 --- esphome/components/tmc2209/__init__.py | 44 +++++ esphome/components/tmc2209/stepper.py | 28 ++- esphome/components/tmc2209/tmc2209.cpp | 20 ++ esphome/components/tmc2209/tmc2209.h | 39 ++++ esphome/components/tmc22xx/__init__.py | 18 +- esphome/components/tmc22xx/tmc22xx.cpp | 186 ++++++++++++++++-- esphome/components/tmc22xx/tmc22xx.h | 55 ++++++ tests/components/tmc2209/common.yaml | 29 +++ tests/components/tmc2209/test.esp32-idf.yaml | 1 + .../components/tmc2209/test.esp8266-ard.yaml | 1 + tests/components/tmc2209/test.rp2040-ard.yaml | 1 + 11 files changed, 404 insertions(+), 18 deletions(-) diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py index b3822f7582e..06dd8603ff5 100644 --- a/esphome/components/tmc2209/__init__.py +++ b/esphome/components/tmc2209/__init__.py @@ -1,10 +1,54 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import tmc22xx +import esphome.config_validation as cv +from esphome.const import CONF_THRESHOLD CODEOWNERS = ["@remcom"] DOMAIN = "tmc2209" +CONF_SEDN = "sedn" +CONF_SEIMIN = "seimin" +CONF_SEMAX = "semax" +CONF_SEMIN = "semin" +CONF_SEUP = "seup" +CONF_TCOOL_THRESHOLD = "tcool_threshold" + tmc2209_ns = cg.esphome_ns.namespace("tmc2209") TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) tmc22xx.register_actions("tmc2209", TMC2209Stepper) + +automation.register_apply_action( + "tmc2209.stallguard", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8), + automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32), +) + +automation.register_apply_action( + "tmc2209.coolconf", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8), + automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8), + automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8), + automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8), + automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_), +) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py index 5ef91dc591f..8867242aa25 100644 --- a/esphome/components/tmc2209/stepper.py +++ b/esphome/components/tmc2209/stepper.py @@ -1,4 +1,6 @@ +from esphome import automation from esphome.components import tmc22xx +import esphome.config_validation as cv from esphome.types import ConfigType from . import TMC2209Stepper @@ -6,9 +8,31 @@ from . import TMC2209Stepper AUTO_LOAD = ["tmc22xx"] DEPENDENCIES = ["uart"] -CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +CONF_ON_STALL = "on_stall" + + +def _validate_stall_needs_diag(config: ConfigType) -> ConfigType: + if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config: + raise cv.Invalid( + f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG" + ) + return config + + +CONFIG_SCHEMA = cv.All( + tmc22xx.tmc22xx_schema( + TMC2209Stepper, + max_address=3, + extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})}, + ), + _validate_stall_needs_diag, +) FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate async def to_code(config: ConfigType) -> None: - await tmc22xx.new_tmc22xx(config) + var = await tmc22xx.new_tmc22xx(config) + for conf in config.get(CONF_ON_STALL, []): + await automation.build_callback_automation( + var, "add_on_stall_callback", [], conf + ) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp index 040b362a8d5..21625a5147d 100644 --- a/esphome/components/tmc2209/tmc2209.cpp +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -10,4 +10,24 @@ void TMC2209Stepper::dump_config() { TMC22XXStepper::dump_config(); } +uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_TCOOLTHRS: + return &this->tcoolthrs_; + case REG_SGTHRS: + return &this->sgthrs_; + case REG_COOLCONF: + return &this->coolconf_; + default: + return TMC22XXStepper::shadow_register(reg); + } +} + +void TMC2209Stepper::restore_registers() { + TMC22XXStepper::restore_registers(); + this->write_register(REG_TCOOLTHRS, this->tcoolthrs_); + this->write_register(REG_SGTHRS, this->sgthrs_); + this->write_register(REG_COOLCONF, this->coolconf_); +} + } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index 2f880251e56..f1c83485fa3 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -4,12 +4,51 @@ namespace esphome::tmc2209 { +using tmc22xx::RegisterField; + +// StallGuard and CoolStep registers, which the TMC2208 does not have +static constexpr uint8_t REG_TCOOLTHRS = 0x14; +static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_COOLCONF = 0x42; + +static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; +static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; +static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; +static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; +static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false}; +static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false}; + class TMC2209Stepper : public tmc22xx::TMC22XXStepper { public: void dump_config() override; + /// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS. + template void add_on_stall_callback(F &&callback) { + this->stall_callback_.add(std::forward(callback)); + } + + /// A stall is reported when the StallGuard result drops below twice this value. + void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. + void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } + void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } + void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); } + void set_seup(uint8_t seup) { this->write_field(SEUP, seup); } + void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); } + void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); } + protected: uint8_t expected_version() const override { return 0x21; } + uint32_t *shadow_register(uint8_t reg) override; + void restore_registers() override; + void on_diag_without_error() override { this->stall_callback_.call(); } + + LazyCallbackManager stall_callback_; + // Write-only registers with their power-on values + uint32_t tcoolthrs_{0}; + uint32_t sgthrs_{0}; + uint32_t coolconf_{0}; }; } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ad7221f0b21..da760dde2c5 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -21,6 +21,7 @@ DEPENDENCIES = ["uart"] CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" CONF_HOLD_CURRENT = "hold_current" @@ -31,6 +32,7 @@ CONF_INTERPOLATION = "interpolation" CONF_INVERSE_DIRECTION = "inverse_direction" CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" +CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" @@ -44,6 +46,7 @@ TMC22XXStepper = tmc22xx_ns.class_( "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice ) StandstillMode = tmc22xx_ns.enum("StandstillMode") +DriverEvent = tmc22xx_ns.enum("DriverEvent") STANDSTILL_MODES = { "normal": StandstillMode.STANDSTILL_MODE_NORMAL, @@ -56,7 +59,9 @@ validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) validate_current = cv.All(cv.current, cv.positive_float) -def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: +def tmc22xx_schema( + cls: MockObjClass, max_address: int, extra: dict | None = None +) -> cv.Schema: """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" return cv.All( stepper.STEPPER_SCHEMA.extend( @@ -67,6 +72,7 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_DIAG_PIN): pins.internal_gpio_input_pin_schema, cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( cv.frequency, cv.int_range(min=1) ), @@ -79,6 +85,8 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_RUN_CURRENT): validate_current, cv.Optional(CONF_HOLD_CURRENT): validate_current, cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + cv.Optional(CONF_ON_STATUS): automation.validate_automation({}), + **(extra or {}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +141,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_STEP_PIN, var.set_step_pin), (CONF_DIR_PIN, var.set_dir_pin), (CONF_INDEX_PIN, var.set_index_pin), + (CONF_DIAG_PIN, var.set_diag_pin), ): if (pin := config.get(key)) is not None: cg.add(setter(await cg.gpio_pin_expression(pin))) @@ -145,6 +154,13 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: ): if (value := config.get(key)) is not None: cg.add(setter(value)) + for conf in config.get(CONF_ON_STATUS, []): + await automation.build_callback_automation( + var, + "add_on_status_callback", + [(DriverEvent, "event"), (cg.bool_, "active")], + conf, + ) return var diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 1628572d406..2aed97bea10 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -23,6 +23,49 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis static constexpr float APPROACH_TIME_S = 0.02f; // Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; +static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000; +// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read +// blocks the loop for the UART timeout +static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9; +static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp +static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF; + +static const LogString *driver_event_to_string(DriverEvent event) { + switch (event) { + case DRIVER_EVENT_RESET: + return LOG_STR("Driver reset"); + case DRIVER_EVENT_DRIVER_ERROR: + return LOG_STR("Driver error"); + case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE: + return LOG_STR("Charge pump undervoltage"); + case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING: + return LOG_STR("Overtemperature prewarning"); + case DRIVER_EVENT_OVERTEMPERATURE: + return LOG_STR("Overtemperature shutdown"); + case DRIVER_EVENT_SHORT_TO_GROUND_A: + return LOG_STR("Short to ground on phase A"); + case DRIVER_EVENT_SHORT_TO_GROUND_B: + return LOG_STR("Short to ground on phase B"); + case DRIVER_EVENT_LOW_SIDE_SHORT_A: + return LOG_STR("Low side short on phase A"); + case DRIVER_EVENT_LOW_SIDE_SHORT_B: + return LOG_STR("Low side short on phase B"); + case DRIVER_EVENT_OPEN_LOAD_A: + return LOG_STR("Open load on phase A"); + case DRIVER_EVENT_OPEN_LOAD_B: + return LOG_STR("Open load on phase B"); + case DRIVER_EVENT_TEMPERATURE_120C: + return LOG_STR("Temperature above 120C"); + case DRIVER_EVENT_TEMPERATURE_143C: + return LOG_STR("Temperature above 143C"); + case DRIVER_EVENT_TEMPERATURE_150C: + return LOG_STR("Temperature above 150C"); + case DRIVER_EVENT_TEMPERATURE_157C: + return LOG_STR("Temperature above 157C"); + default: + return LOG_STR("Unknown"); + } +} // CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first static uint8_t datagram_crc(const uint8_t *data, size_t len) { @@ -34,8 +77,19 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) { return crc; } +// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset +static int restored_index(uint8_t reg) { + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if (RESTORED_REGISTERS[i] == reg) + return static_cast(i); + } + return -1; +} + void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } +void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; } + void TMC22XXStepper::setup() { if (this->enn_pin_ != nullptr) { this->enn_pin_->setup(); @@ -53,13 +107,54 @@ void TMC22XXStepper::setup() { ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); } - // Bring write-only registers in line with their cached values and clear the reset flag + this->restore_registers(); + this->write_register(REG_GSTAT, GSTAT_FLAGS); + this->apply_config_(); + // Currents live in a write-only register, so later changes survive a driver reset through its cached value + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + if (this->diag_pin_ != nullptr) { + this->diag_pin_->setup(); + this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE); + } + // Polling notices a driver reset, and conditions that clear without a DIAG edge + this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() { + if (this->status_poll_skip_ > 0) { + this->status_poll_skip_--; + return; + } + if (!this->update_status_()) + this->status_poll_skip_ = UNREACHABLE_POLL_SKIP; + }); + + this->set_enabled(true); +} + +void TMC22XXStepper::restore_registers() { this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); this->write_register(REG_TPOWERDOWN, this->tpowerdown_); this->write_register(REG_TPWMTHRS, this->tpwmthrs_); this->write_register(REG_VACTUAL, 0); - this->write_register(REG_GSTAT, 0x07); + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if ((this->restored_written_ & (1u << i)) != 0) + this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]); + } +} +void TMC22XXStepper::apply_config_() { this->write_field(PDN_DISABLE, true); this->write_field(MSTEP_REG_SELECT, true); this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); @@ -76,28 +171,59 @@ void TMC22XXStepper::setup() { this->toff_ = *toff; this->set_microsteps(this->initial_microsteps_); - if (!std::isnan(this->initial_run_current_)) - this->set_run_current(this->initial_run_current_); - if (!std::isnan(this->initial_hold_current_)) - this->set_hold_current(this->initial_hold_current_); if (this->index_pin_ != nullptr) { // INDEX toggles on every step of the internal pulse generator that VACTUAL drives this->write_field(INDEX_OTPW, false); this->write_field(INDEX_STEP, true); - this->index_pin_->setup(); - this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); } else { // Step on both edges, so every step pin toggle is one step this->write_field(DEDGE, true); this->write_field(MULTISTEP_FILT, false); - this->step_pin_->setup(); - this->step_pin_->digital_write(false); - this->dir_pin_->setup(); - this->dir_pin_->digital_write(false); } +} - this->set_enabled(true); +bool TMC22XXStepper::update_status_() { + auto gstat = this->read_register(REG_GSTAT); + if (!gstat.has_value()) + return false; + uint32_t drv_status = 0; + // DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used + if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) { + auto value = this->read_register(REG_DRV_STATUS); + if (!value.has_value()) + return false; + drv_status = *value; + } + // GSTAT flags stay set until written with 1; they are set again while the condition lasts + if ((*gstat & GSTAT_FLAGS) != 0) + this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS); + + uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3); + // Restore before reporting, so an on_status automation for the reset sees and can change the restored settings + if ((status & (1u << DRIVER_EVENT_RESET)) != 0) { + ESP_LOGW(TAG, "Restoring settings after driver reset"); + this->restore_registers(); + // The motor stopped with the reset, ramp up again from standstill + this->current_speed_ = 0.0f; + this->set_enabled(this->enabled_); + } + uint16_t changed = status ^ this->status_; + this->status_ = status; + for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) { + if ((changed & (1u << event)) == 0) + continue; + bool active = (status & (1u << event)) != 0; + // Open load and temperature levels are informational, the driver keeps running + if (active && event < DRIVER_EVENT_OPEN_LOAD_A) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast(event)))); + } else { + ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast(event))), + active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared")); + } + this->status_callback_.call(static_cast(event), active); + } + return true; } void TMC22XXStepper::dump_config() { @@ -128,6 +254,7 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" STEP Pin: ", this->step_pin_); LOG_PIN(" DIR Pin: ", this->dir_pin_); LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -137,12 +264,29 @@ void TMC22XXStepper::dump_config() { void TMC22XXStepper::loop() { if (this->index_pin_ != nullptr) this->add_index_pulses_(); + if (this->diag_store_.triggered) { + this->diag_store_.triggered = false; + // DIAG rises on a driver error, any other rise is chip specific + if (!this->update_status_()) { + ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read"); + } else if ((this->status_ & GSTAT_FLAGS) == 0) { + this->on_diag_without_error(); + } + } + if (this->has_reached_target()) { this->high_freq_.stop(); + this->moving_ = false; } else { this->high_freq_.start(); if (!this->enabled_) this->set_enabled(true); + // An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the + // motor supply dropped are restored before the move runs on the chip defaults. + if (!this->moving_ && this->status_poll_skip_ > 0) { + this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP; + } + this->moving_ = true; } if (this->index_pin_ != nullptr) { @@ -311,8 +455,12 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { } this->status_clear_warning(); - if (uint32_t *shadow = this->shadow_register(reg)) + if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; + } else if (int index = restored_index(reg); index >= 0) { + this->restored_values_[index] = value; + this->restored_written_ |= 1u << index; + } return true; } @@ -346,7 +494,15 @@ bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { // Skip the UART timeout when the driver never answered during setup if (this->is_failed()) return false; - auto data = this->read_register(field.reg); + optional data; + int index = restored_index(field.reg); + if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) { + // Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip + // holds its defaults, and those would replace the values to restore + data = this->restored_values_[index]; + } else { + data = this->read_register(field.reg); + } if (!data.has_value()) return false; uint32_t mask = field.mask(); diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 8b0c92d3520..546a27e70d3 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/stepper/stepper.h" @@ -30,8 +31,12 @@ static constexpr uint8_t REG_TPOWERDOWN = 0x11; static constexpr uint8_t REG_TPWMTHRS = 0x13; static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +// Readable registers that a driver reset sets back to their defaults; the last written values are restored +static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; + static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; @@ -63,6 +68,33 @@ enum StandstillMode : uint8_t { STANDSTILL_MODE_COIL_SHORT_HS = 3, }; +/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits +/// 0-11. +enum DriverEvent : uint8_t { + DRIVER_EVENT_RESET = 0, + DRIVER_EVENT_DRIVER_ERROR, + DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE, + DRIVER_EVENT_OVERTEMPERATURE_PREWARNING, + DRIVER_EVENT_OVERTEMPERATURE, + DRIVER_EVENT_SHORT_TO_GROUND_A, + DRIVER_EVENT_SHORT_TO_GROUND_B, + DRIVER_EVENT_LOW_SIDE_SHORT_A, + DRIVER_EVENT_LOW_SIDE_SHORT_B, + DRIVER_EVENT_OPEN_LOAD_A, + DRIVER_EVENT_OPEN_LOAD_B, + DRIVER_EVENT_TEMPERATURE_120C, + DRIVER_EVENT_TEMPERATURE_143C, + DRIVER_EVENT_TEMPERATURE_150C, + DRIVER_EVENT_TEMPERATURE_157C, + DRIVER_EVENT_COUNT, +}; + +/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall. +struct DiagPinStore { + volatile bool triggered{false}; + static void gpio_intr(DiagPinStore *arg); +}; + /// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. struct IndexPulseStore { volatile int32_t pulses{0}; @@ -84,6 +116,7 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_diag_pin(InternalGPIOPin *pin) { this->diag_pin_ = pin; } void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } void set_rsense(float rsense) { this->rsense_ = rsense; } void set_vsense(bool vsense) { this->vsense_ = vsense; } @@ -97,6 +130,11 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// Called with the condition and whether it became active or cleared. + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + void set_microsteps(uint16_t microsteps); uint16_t get_microsteps(); void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } @@ -127,6 +165,15 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U virtual uint8_t expected_version() const = 0; /// Return the cached value of a write-only register, or nullptr when the register is readable. virtual uint32_t *shadow_register(uint8_t reg); + /// Write the cached write-only registers and the last written configuration registers to the driver, at setup + /// and after a driver reset. + virtual void restore_registers(); + /// Write the settings from the configuration to the readable registers. + void apply_config_(); + /// Called when DIAG rises without a driver error. + virtual void on_diag_without_error() {} + /// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read. + bool update_status_(); optional current_to_scale_(float current); float full_scale_current_(); int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } @@ -139,28 +186,36 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U GPIOPin *step_pin_{nullptr}; GPIOPin *dir_pin_{nullptr}; InternalGPIOPin *index_pin_{nullptr}; + InternalGPIOPin *diag_pin_{nullptr}; + LazyCallbackManager status_callback_; uint32_t clock_frequency_{12000000}; // Write-only registers shared by all family members, with their power-on values uint32_t ihold_irun_{0x00071703}; uint32_t tpowerdown_{0x14}; uint32_t tpwmthrs_{0}; uint32_t vactual_reg_{0}; + std::array restored_values_{}; // NaN while not configured float rsense_{NAN}; float initial_run_current_{NAN}; float initial_hold_current_{NAN}; IndexPulseStore index_store_{}; uint16_t initial_microsteps_{16}; + uint16_t status_{0}; optional vsense_{}; optional ottrim_{}; uint8_t address_{0}; uint8_t toff_{3}; uint8_t version_{0}; + uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written + uint8_t status_poll_skip_{0}; int8_t direction_{0}; + DiagPinStore diag_store_{}; bool analog_current_scale_{false}; bool enabled_{false}; bool vsense_active_{false}; bool step_state_{false}; + bool moving_{false}; HighFrequencyLoopRequester high_freq_; }; diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index ff10c79edcf..e53c3ba8233 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -6,6 +6,7 @@ stepper: step_pin: ${step_pin} dir_pin: ${dir_pin} enn_pin: ${enn_pin} + diag_pin: ${diag_pin} rsense: 110 mOhm vsense: false ottrim: 1 @@ -15,6 +16,17 @@ stepper: max_speed: 2000 steps/s acceleration: 1000 steps/s^2 deceleration: 1000 steps/s^2 + on_stall: + - stepper.report_position: + id: tmc2209_step_dir + position: 0 + - stepper.set_target: + id: tmc2209_step_dir + target: 0 + on_status: + - logger.log: + format: "Driver event %u active %d" + args: [event, active] - platform: tmc2209 id: tmc2209_serial @@ -24,6 +36,12 @@ stepper: clock_frequency: 12MHz analog_current_scale: true max_speed: 1000 steps/s + on_status: + - if: + condition: + lambda: "return event == tmc22xx::DRIVER_EVENT_OVERTEMPERATURE_PREWARNING && active;" + then: + - tmc2209.disable: tmc2209_serial esphome: on_boot: @@ -49,6 +67,17 @@ esphome: id: tmc2209_serial irun: 20 ihold: !lambda "return 5;" + - tmc2209.stallguard: + id: tmc2209_step_dir + threshold: 50 + tcool_threshold: !lambda "return 400;" + - tmc2209.coolconf: + id: tmc2209_step_dir + semin: 5 + semax: 2 + seup: 1 + sedn: 0 + seimin: true - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml index 5634c186860..18fcfbcaed0 100644 --- a/tests/components/tmc2209/test.esp32-idf.yaml +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO4 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml index 3e4ea33f7a5..fe1c6289af0 100644 --- a/tests/components/tmc2209/test.esp8266-ard.yaml +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO2 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml index 722f8f201fc..d7ceff9ee1f 100644 --- a/tests/components/tmc2209/test.rp2040-ard.yaml +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO3 enn_pin: GPIO6 index_pin: GPIO7 + diag_pin: GPIO8 packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml From 3063b76210acbbf4102910dfe6a5d10d5ce24c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 08:25:36 -1000 Subject: [PATCH 43/66] [haier_tundra] Build the IR frame on the stack (#20635) --- esphome/components/haier_tundra/haier_tundra.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp index 7a0b88d3aac..a3a5e0a8400 100644 --- a/esphome/components/haier_tundra/haier_tundra.cpp +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -28,7 +28,7 @@ void HaierTundra::transmit_(uint8_t button) { this->turbo_mode_ = false; } - remote_base::HaierData remote_state{{ + const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = { this->unit_, static_cast(this->temperature_() << 4 | this->vertical_swing_()), static_cast(this->horizontal_swing_() << 5), @@ -42,10 +42,10 @@ void HaierTundra::transmit_(uint8_t button) { static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), 0x00, button, - }}; + }; auto transmit = this->transmitter_->transmit(); - remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame)); transmit.perform(); } From a82705892800258d590e630fc8b105a00b08283d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:27:50 +1100 Subject: [PATCH 44/66] [lvgl] Change deprecated calls to current version (#20620) --- esphome/components/lvgl/color_picker.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h index e2392694f07..1949ac00de0 100644 --- a/esphome/components/lvgl/color_picker.h +++ b/esphome/components/lvgl/color_picker.h @@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound { if (left != nullptr) { lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); // A narrow hue column may be slimmer than its label. - lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); } // The text rows keep their natural height and the bars between them take what is left, @@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound { static lv_obj_t *create_box(lv_obj_t *parent) { auto *obj = lv_obj_create(parent); lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false); return obj; } @@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); - lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false); return disc; } @@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound { this->wheel_ = create_disc(container, &wheel_hue_grad); // Not clickable, so touches fall through to the wheel underneath. auto *overlay = create_disc(this->wheel_, &wheel_white_grad); - lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false); this->wheel_handle_ = lv_obj_create(this->wheel_); lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); @@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false); // PRESSED as well, so a tap takes effect without the finger moving. lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); @@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound { void add_value_label_(SliderIndex index, lv_color_t color) { auto *slider = this->sliders_[index]; - lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); auto *label = lv_label_create(slider); this->values_[index] = label; lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); @@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound { static void bubble_events(lv_obj_t *obj) { for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { auto *child = lv_obj_get_child(obj, i); - lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true); bubble_events(child); } } From 0821c84b7aea3c9c47aadb16c23aa7bb736a04eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:24 -0400 Subject: [PATCH 45/66] [core] Let a nested repeat shadow the outer iteration variable (#20634) --- esphome/automation.py | 17 +++++++- tests/components/script/common.yaml | 17 ++++++++ tests/unit_tests/test_automation.py | 65 ++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a2c10cb52d..32f423d2a34 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -906,6 +906,21 @@ async def while_action_to_code( return var +def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType: + """Rename an inherited `iteration` so the inner repeat's own counter can shadow it.""" + names = {name for _, name in args} + renamed: TemplateArgsType = [] + for type_, name in args: + if name == "iteration": + depth = 1 + while f"iteration_{depth}" in names: + depth += 1 + name = f"iteration_{depth}" + names.add(name) + renamed.append((type_, name)) + return renamed + + @register_action( "repeat", RepeatAction, @@ -929,7 +944,7 @@ async def repeat_action_to_code( actions = await build_action_list( config[CONF_THEN], cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *args], + [(cg.uint32, "iteration"), *_shadow_iteration(args)], ) for action in actions: cg.add(var.add_then_action(action)) diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 6b7290277a1..f8396e9648f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -23,6 +23,10 @@ esphome: then: - logger.log: my_script is running +globals: + - id: nested_total + type: int + script: - id: my_script mode: single @@ -71,3 +75,16 @@ script: count: 5 then: - logger.log: looping! + - repeat: + count: 2 + then: + - repeat: + count: 2 + then: + - delay: 200ms + - lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);' + - globals.set: + id: nested_total + value: !lambda |- + int total = iteration; + return total; diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fe0b1b02ed1..b56d0a2920e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -13,9 +13,13 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + LambdaAction, + RepeatAction, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + _shadow_iteration, + build_action, build_callback_automation, build_callback_automations, build_parent_callback_automation, @@ -41,7 +45,13 @@ from esphome.automation import ( ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_COUNT, + CONF_ID, + CONF_THEN, + CONF_TYPE_ID, +) from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.types import ConfigType @@ -1545,3 +1555,56 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None assert str(mock_cg.add.call_args.args[0]) == ( f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" ) + + +def test_shadow_iteration_leaves_other_names() -> None: + args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] + assert _shadow_iteration(args) == args + + +def test_shadow_iteration_renames_inherited_counter() -> None: + assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [ + (cg.uint32, "iteration_1"), + (cg.bool_, "x"), + ] + + +def test_shadow_iteration_picks_first_unused_suffix() -> None: + """A counter keeps its suffix at deeper levels; the new one takes the next free name.""" + args = [ + (cg.uint32, "iteration"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + assert _shadow_iteration(args) == [ + (cg.uint32, "iteration_2"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + + +def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType: + return { + "repeat": {CONF_COUNT: 2, CONF_THEN: then}, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction), + } + + +@pytest.mark.asyncio +async def test_nested_repeat_lambda_parameters_are_distinct() -> None: + """Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated.""" + CORE.config = {} + log = { + "lambda": Lambda('ESP_LOGD("t", "%u", iteration);'), + CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction), + } + conf = _repeat_conf( + "outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])] + ) + await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")]) + text = CORE.cpp_main_section + assert ( + "(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)" + in text + ) + assert "uint32_t iteration, uint32_t iteration)" not in text From 417ab4d2e940d54fa2d9b11eaa79946e196df1d9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 13 Oct 2026 15:00:45 -0400 Subject: [PATCH 46/66] [openthread] Add core_options for OpenThread compile-time options (#20595) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/__init__.py | 68 +++++++ esphome/components/openthread/const.py | 3 + .../openthread/config/custom_header.yaml | 19 ++ .../openthread/config/no_custom_header.yaml | 16 ++ tests/component_tests/openthread/test_init.py | 171 +++++++++++++++++- .../openthread/test.esp32-c6-idf.yaml | 2 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/openthread/config/custom_header.yaml create mode 100644 tests/component_tests/openthread/config/no_custom_header.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6fca8bfdd9a..2c278f12f49 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +import re from typing import Any from esphome import automation @@ -44,9 +45,12 @@ from esphome.core import ( coroutine_with_priority, ) import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_build from .const import ( + CONF_CORE_OPTIONS, CONF_DEVICE_TYPE, CONF_EXT_PAN_ID, CONF_FORCE_DATASET, @@ -59,6 +63,7 @@ from .const import ( CONF_PSKC, CONF_SRP_ID, CONF_TLV, + OPENTHREAD_CONFIG_PREFIX, ) CODEOWNERS = ["@mrene"] @@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [ "MTD", ] +OT_HEADER_DIR = "openthread" +OT_HEADER_FILE = "esphome_ot_config.h" +OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") + def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: @@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) +def _write_custom_header(config: ConfigType) -> None: + """Write core_options to the OpenThread custom header.""" + if not (options := config.get(CONF_CORE_OPTIONS)): + return + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True) + # IDF resolves the path relative to the project (build) directory. + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE + ) + content = ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + + "".join( + f"#define {name} {value}\n" for name, value in sorted(options.items()) + ) + ) + # PlatformIO does not track this header as an OpenThread input; the esp-idf + # toolchain does. + header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE) + if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf: + clean_build(clear_pio_cache=False) + + openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) @@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType: )(config) +def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]: + for name in options: + if ( + not name.startswith(OPENTHREAD_CONFIG_PREFIX) + or name == OPENTHREAD_CONFIG_PREFIX + ): + raise cv.Invalid( + f"'{name}' is not an OpenThread core option; names must be " + f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name", + path=[name], + ) + if not OT_CORE_OPTION_NAME.fullmatch(name): + raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name]) + return options + + +def _validate_core_option_value(value: Any) -> str: + value = cv.string_strict(value) + if not value.strip(): + raise cv.Invalid("OpenThread core option value must not be empty") + if "\n" in value or "\r" in value: + raise cv.Invalid("OpenThread core option value must be a single line") + # GCC treats a backslash followed by spaces as a line continuation too. + if value.rstrip().endswith("\\"): + raise cv.Invalid("OpenThread core option value must not end with \\") + return value + + def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: @@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All( _validate_txpower, ), cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CORE_OPTIONS): cv.All( + cv.only_on_esp32, + {cv.string_strict: _validate_core_option_value}, + _validate_core_option_names, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) + _write_custom_header(config) elif CORE.using_zephyr: # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 include_west_project("mbedtls") diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index f0274a8c9e5..bd9c8cfa1c7 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -1,3 +1,4 @@ +CONF_CORE_OPTIONS = "core_options" CONF_DEVICE_TYPE = "device_type" CONF_EXT_PAN_ID = "ext_pan_id" CONF_FORCE_DATASET = "force_dataset" @@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" + +OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_" diff --git a/tests/component_tests/openthread/config/custom_header.yaml b/tests/component_tests/openthread/config/custom_header.yaml new file mode 100644 index 00000000000..767497e9b2f --- /dev/null +++ b/tests/component_tests/openthread/config/custom_header.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + core_options: + OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1" + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" diff --git a/tests/component_tests/openthread/config/no_custom_header.yaml b/tests/component_tests/openthread/config/no_custom_header.yaml new file mode 100644 index 00000000000..df0634e98d7 --- /dev/null +++ b/tests/component_tests/openthread/config/no_custom_header.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index 7ba9bdd4bba..c0e155ec7e3 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -1,11 +1,22 @@ -"""Tests for the openthread component schema.""" +"""Tests for the openthread component.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 -from esphome.components.openthread import CONFIG_SCHEMA -from esphome.const import PlatformFramework -from esphome.core import HexInt +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, + VARIANT_ESP32C6, +) +from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE +from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX +import esphome.config_validation as cv +from esphome.const import PlatformFramework, Toolchain +from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -37,3 +48,153 @@ def test_credentials_are_sensitive( assert isinstance(validated[field], Sensitive) assert validated[field] == expected assert isinstance(validated[field], HexInt) == isinstance(expected, int) + + +def _generate_in( + tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str] +) -> tuple[Path, MagicMock]: + """Generate from ``yaml_text`` written to tmp_path, so the build directory lands there. + + Returns the header path and the mocked ``clean_build``. + """ + config_path = tmp_path / "test.yaml" + config_path.write_text(yaml_text, encoding="utf-8") + with patch("esphome.components.openthread.clean_build") as clean_build: + generate_main(config_path) + return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build + + +def test_core_options_written_to_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, _ = _generate_in( + tmp_path, + component_config_path("custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE + assert header.read_text(encoding="utf-8") == ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + "#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n" + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n" + ) + + +def test_no_header_options_no_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, clean_build = _generate_in( + tmp_path, + component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig + assert not header.exists() + clean_build.assert_not_called() + + +@pytest.mark.parametrize( + ("toolchain", "cleans"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)], +) +def test_header_change_cleans_only_on_platformio( + toolchain: Toolchain, + cleans: bool, + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = component_config_path("custom_header.yaml").read_text(encoding="utf-8") + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + assert clean_build.called is cleans + # Unchanged content: no clean on either toolchain. + CORE.reset() + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + clean_build.assert_not_called() + # A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread. + changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"') + assert changed != source + CORE.reset() + CORE.toolchain = toolchain + header, clean_build = _generate_in(tmp_path, changed, generate_main) + assert clean_build.called is cleans + assert ( + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n" + in header.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + "name", + [ + "CONFIG_OPENTHREAD_JOINER", + "openthread_config_x", + "OT_CONFIG_X", + OPENTHREAD_CONFIG_PREFIX, + ], +) +def test_core_option_without_prefix_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + "name", + ["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"], +) +def test_core_option_bad_characters_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be empty"), + (" ", "must not be empty"), + ("1\n#define X 2", "must be a single line"), + ("1\r2", "must be a single line"), + ("1 \\", "must not end with"), + ("1 \\ ", "must not end with"), + ], +) +def test_core_option_bad_value_rejected( + value: str, message: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=message): + CONFIG_SCHEMA( + { + "network_key": hex(NETWORK_KEY), + "core_options": {"OPENTHREAD_CONFIG_X": value}, + } + ) + + +def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="ESP32"): + CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}}) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 92d120e5d19..746b7151f30 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -9,3 +9,5 @@ openthread: pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 output_power: 1dBm + core_options: + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" From 918b7d255357a814327b8b7052d4cb2a3c3a9437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 47/66] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 05bf7d34408e7358411c5bd1a5e24717093d7bf5 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:40:56 +0200 Subject: [PATCH 48/66] [epaper_spi] Write pixel blocks straight into mono buffers (#20552) --- .../components/display/display_color_utils.h | 52 +++++ esphome/components/epaper_spi/colorconv.h | 19 +- esphome/components/epaper_spi/epaper_spi.cpp | 114 +++++++++++ esphome/components/epaper_spi/epaper_spi.h | 6 + .../components/epaper_spi/epaper_spi_mono.h | 1 + .../epaper_spi/epaper_spi_ssd1677.cpp | 4 +- .../epaper_spi/epaper_spi_ssd1677.h | 3 - .../epaper_spi/epaper_spi_ssd1677_gray4.h | 2 +- .../components/epaper_spi/epaper_spi_uc8179.h | 1 + esphome/components/it8951/it8951.cpp | 10 +- .../epaper_spi/display/test_draw_pixels.cpp | 193 ++++++++++++++++++ 11 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_draw_pixels.cpp diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 608642caa44..be39e2a3e2f 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -1,4 +1,6 @@ #pragma once +#include +#include #include "esphome/core/color.h" namespace esphome::display { @@ -154,4 +156,54 @@ class ColorUtil { return color; } }; + +/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */ +static constexpr uint16_t REC601_LUMA_R = 77; +static constexpr uint16_t REC601_LUMA_G = 150; +static constexpr uint16_t REC601_LUMA_B = 29; + +/** Rec.601 luma of an RGB color, for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8); +} + +/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division + * per channel. + */ +class Rgb565ToMono { + public: + explicit Rgb565ToMono(ColorOrder order) { + // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order + uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; + if (order == COLOR_ORDER_BGR) { + high = REC601_LUMA_B; + low = REC601_LUMA_R; + } else if (order == COLOR_ORDER_GRB) { + high = REC601_LUMA_G; + middle = REC601_LUMA_R; + } + // Each field value scaled to 0..255, then weighted + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + + uint8_t operator()(uint16_t value) const { + const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F]; + return ((weighted + 128u) >> 8) >= 128 ? 1 : 0; + } + + protected: + // Weighted contribution of each value of each field + std::array high_{}; + std::array middle_{}; + std::array low_{}; +}; + } // namespace esphome::display diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 0240aa5cccf..cb59538abab 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/color.h" /* Utility for converting internal \a Color RGB representation to supported IC hardware color keys @@ -13,21 +14,15 @@ namespace esphome::epaper_spi { +// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other +// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too. +using display::color_to_mono; +using display::rec601_luma; +using display::Rgb565ToMono; + /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; -/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ -constexpr uint8_t rec601_luma(Color color) { - return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); -} - -/** Map RGB color to a single monochrome bit - * - * @param color RGB color to convert from - * @return 1 = white, 0 = black - */ -constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } - /** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) * * @param color RGB color to convert from diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0c1d9b0fafb..2a2f74763f0 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -345,6 +345,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } } +namespace { +// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies +// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step +// rows. +template +void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count, + const Rgb565ToMono &to_mono) { + const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; + for (; count != 0; count--, source += 2) { + const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + if (to_mono(value)) { + *byte |= mask; + } else { + *byte &= ~mask; + } + if constexpr (SwapAxes) { + byte += byte_step; + } else if (step > 0) { + mask >>= 1; + if (mask == 0) { + mask = 0x80; + byte++; + } + } else { + mask <<= 1; + if (mask == 0) { + mask = 0x01; + byte--; + } + } + } +} +} // namespace + +// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so +// the visible part of a block and its dirty area are worked out once for the whole block. +void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) { + const bool swap = this->effective_transform_ & SWAP_XY; + const bool mirror_x = this->effective_transform_ & MIRROR_X; + const bool mirror_y = this->effective_transform_ & MIRROR_Y; + // The buffer axis a source column moves along, and the one a source row moves along + const int column_limit = swap ? this->height_ : this->width_; + const int row_limit = swap ? this->width_ : this->height_; + const bool column_mirrored = swap ? mirror_y : mirror_x; + const bool row_mirrored = swap ? mirror_x : mirror_y; + const int first_column = std::max(0, -x_start); + const int end_column = std::min(w, column_limit - x_start); + const int first_row = std::max(0, -y_start); + const int end_row = std::min(h, row_limit - y_start); + if (first_column >= end_column || first_row >= end_row) + return; + auto column_to_buffer = [&](int column) { + const int u = x_start + column; + return column_mirrored ? column_limit - 1 - u : u; + }; + auto row_to_buffer = [&](int row) { + const int v = y_start + row; + return row_mirrored ? row_limit - 1 - v : v; + }; + + const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1)); + const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1)); + const int columns = end_column - first_column; + const int rows = end_row - first_row; + const int x_low = swap ? row_low : column_low; + const int y_low = swap ? column_low : row_low; + const int x_high = x_low + (swap ? rows : columns); + const int y_high = y_low + (swap ? columns : rows); + + const Rgb565ToMono to_mono(order); + const size_t line_stride = x_offset + w + x_pad; + const size_t row_width = this->row_width_; + const int step = column_mirrored ? -1 : 1; + for (int row = first_row; row != end_row; row++) { + const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2; + const int along = column_to_buffer(first_column); + const int across = row_to_buffer(row); + const int x = swap ? across : along; + const int y = swap ? along : across; + uint8_t *byte = buffer + y * row_width + x / 8; + const uint8_t mask = 0x80u >> (x % 8); + if (swap) { + if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } else if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } + this->x_low_ = clamp_at_most(this->x_low_, x_low); + this->x_high_ = clamp_at_least(this->x_high_, x_high); + this->y_low_ = clamp_at_most(this->y_low_, y_low); + this->y_high_ = clamp_at_least(this->y_high_, y_high); +} + +// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the +// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). +void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + size_t contiguous_length; + uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); + if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + contiguous_length != this->buffer_length_) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 96755a404ec..c7ef90f1160 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -107,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. @@ -128,6 +130,10 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); + void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad); + // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly + virtual bool buffer_is_mono() const { return false; } /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index d0740595fc5..0f570149de4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index a9245fa1c2b..a79bbc3dc91 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - const bool direct = send_copy || this->buffer_is_plane(); + // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper + // buffer (the four-level model) returns false and packs the plane in plane_row(). + const bool direct = send_copy || this->buffer_is_mono(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index ddf3d4e0892..b8c93a1ecb8 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono { void init_comparison_frame_(); // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. size_t plane_row_length_() const { return (this->width_ + 7) / 8; } - // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. - // A subclass with a deeper buffer returns false and overrides plane_row(). - virtual bool buffer_is_plane() const { return true; } // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. virtual void plane_row(size_t y, uint8_t *out); bool reset() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index 47436d2db1d..f21ab3eb069 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { void draw_pixel_at(int x, int y, Color color) override; protected: + bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } - bool buffer_is_plane() const override { return false; } void plane_row(size_t y, uint8_t *out) override; void refresh_screen(bool partial) override; bool transfer_data() override; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index 85c0eb623ee..b12ed54ef6d 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index fe99017774d..565f6f7f8bb 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) { if (color.raw_32 == 0xFFFFFFFF) return 0x0F; // white - // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by - // 256). Rec.601 is the standard for converting SDR images to grayscale and - // spreads saturated colours across the mid-range; Rec.709 instead crams them - // against white/black where the 16 panel levels are hard to tell apart. - auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); - return quantize_8bit_to_nibble(luma); + // Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours + // across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are + // hard to tell apart. + return quantize_8bit_to_nibble(display::rec601_luma(color)); } // 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp new file mode 100644 index 00000000000..a3b47ea8fc1 --- /dev/null +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/colorconv.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel. +template class TestableDisplay : public Driver { + public: + TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) { + EXPECT_TRUE(this->init_buffer_(this->buffer_length_)); + this->reset_bounds(); + } + + void reset_bounds() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + std::vector buffer() const { + std::vector out; + for (size_t i = 0; i != this->buffer_length_; i++) + out.push_back(this->buffer_[i]); + return out; + } + + /// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented. + void split_buffer(size_t max_block) { + ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block)); + ASSERT_GT(this->buffer_.get_buffer_count(), 1u); + this->clear(); + this->reset_bounds(); + } + + std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } + + void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // The per-pixel path the fast one must match + display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + } +}; + +/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold. +static std::vector rgb565(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels; i++) { + state = state * 1103515245u + 12345u; + const uint16_t pixel = static_cast(state >> 8); + out.push_back(pixel & 0xFF); + out.push_back(pixel >> 8); + } + return out; +} + +/// Random bytes for a block of pixels, enough for 3 bytes a pixel. +static std::vector random_bytes(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels * 3; i++) { + state = state * 1103515245u + 12345u; + out.push_back(static_cast(state >> 16)); + } + return out; +} + +/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel, +/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same +/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8. +TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { + struct Block { + int x, y, w, h; + }; + const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}}; + const display::DisplayRotation rotations[] = { + display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES, + display::DISPLAY_ROTATION_270_DEGREES}; + // Swapping the axes comes from the 90 and 270 degree rotations + const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y}; + uint32_t seed = 1; + for (const auto rotation : rotations) { + for (const uint8_t mirroring : mirrorings) { + for (const bool big_endian : {false, true}) { + for (const auto &block : blocks) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + for (auto *display : {&fast, &slow}) { + display->set_transform(mirroring); + display->set_rotation(rotation); + } + // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding + const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) + << " big endian " << big_endian << " block " << block.x << "," + << block.y << " " << block.w << "x" << block.h; + EXPECT_EQ(fast.buffer(), slow.buffer()) << where; + EXPECT_EQ(fast.bounds(), slow.bounds()) << where; + } + } + } + } +} + +/// The colour order reaches the RGB565 block writer. +TEST(EPaperDrawPixels, ColourOrderIsHonoured) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + const auto data = random_bytes(21 * 13, 99); + fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); + } +} + +/// With a clipping region set the block goes through draw_pixel_at(), which honours it. +TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = rgb565(24 * 16, 7); + for (auto *display : {&fast, &slow}) + display->start_clipping(4, 4, 12, 10); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); + EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); +} + +/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too. +TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { + TestableDisplay fast(16, 4); + TestableDisplay slow(16, 4); + const auto data = rgb565(16 * 4, 11); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); +} + +/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + const Rgb565ToMono to_mono(order); + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u) << "order " << int(order); + } +} + +/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. +TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { + { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + fast.split_buffer(7); + slow.split_buffer(7); + const auto data = rgb565(24 * 16, 5); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; + EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; + } + for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = random_bytes(24 * 16, 9); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); + EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); + } +} + +} // namespace esphome::epaper_spi::testing From 8721976066be7f191ae6b481ee812d3558adb7b0 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:47:09 +0200 Subject: [PATCH 49/66] [epaper_spi] Abandon an update when the busy pin never clears (#20555) --- esphome/components/epaper_spi/epaper_spi.cpp | 21 ++- esphome/components/epaper_spi/epaper_spi.h | 9 +- .../epaper_spi/epaper_spi_t133a01.cpp | 9 ++ .../epaper_spi/epaper_spi_t133a01.h | 1 + .../epaper_spi/display/test_busy_timeout.cpp | 145 ++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_busy_timeout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 2a2f74763f0..7b962d59ea8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -131,9 +131,7 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->waiting_for_idle_start_ = millis(); -#endif this->waiting_for_idle_ = should_wait; } @@ -156,6 +154,10 @@ void EPaperBase::loop() { ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { + if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + this->abandon_update_(); + return; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -168,6 +170,20 @@ void EPaperBase::loop() { this->process_state_(); } +// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full +void EPaperBase::abandon_update_() { + ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_, + this->epaper_state_to_string_()); + this->status_set_warning(LOG_STR("Busy pin timeout")); + if (this->state_ == EPaperState::TRANSFER_DATA) + this->abort_transfer(); + // Leave the panel powered down rather than with its high-voltage drivers enabled + this->power_off(); + this->full_update_requested_ = true; + this->current_data_index_ = 0; + this->set_state_(EPaperState::IDLE); +} + /** * Process the state machine. * Typical state sequence: @@ -242,6 +258,7 @@ void EPaperBase::process_state_() { break; case EPaperState::DEEP_SLEEP: this->deep_sleep(); + this->status_clear_warning(); this->set_state_(EPaperState::IDLE); ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index c7ef90f1160..7f1167ae6a4 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -127,6 +127,7 @@ class EPaperBase : public Display, virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); + void abandon_update_(); bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); @@ -143,6 +144,8 @@ class EPaperBase : public Display, * @return true if done, false if it should be called next loop */ virtual bool transfer_data() = 0; + // Called when an update is abandoned in the middle of transfer_data(), to release anything it left open + virtual void abort_transfer() {} /** * Refresh the screen after data transfer */ @@ -193,18 +196,20 @@ class EPaperBase : public Display, // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; + uint32_t waiting_for_idle_start_{}; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE uint32_t waiting_for_idle_last_print_{}; - uint32_t waiting_for_idle_start_{}; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t update_start_time_{}; #endif // properties with specific initialisers go last - EPaperState state_{EPaperState::IDLE}; uint32_t reset_duration_{10}; + // Longer than any panel takes to refresh; past it the busy pin is assumed stuck + uint16_t busy_timeout_seconds_{120}; uint8_t full_update_every_{1}; + EPaperState state_{EPaperState::IDLE}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index bd5a4c9b6e2..585b21f807a 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() { return true; } +void EPaperT133A01::abort_transfer() { + // A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows + if (this->current_data_index_ == 0) + return; + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + void EPaperT133A01::dump_config() { EPaperBase::dump_config(); LOG_PIN(" CS Pin: ", this->cs_pin_); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 0d07fc03ae8..5d356f90b01 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase { void deep_sleep() override; bool transfer_data() override; + void abort_transfer() override; /** * Send a command (and optional data) selecting one or both controllers. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp new file mode 100644 index 00000000000..7080619b2ff --- /dev/null +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -0,0 +1,145 @@ +#include + +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + +template void loop_until_idle(T &display) { + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) + display.loop(); +} + +class TestableSSD1683 : public EPaperSSD1683 { + public: + TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_full_update_every(5); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + EPaperState state() const { return this->state_; } + uint8_t update_count() const { return this->update_count_; } + void set_update_count(uint8_t count) { this->update_count_ = count; } + + RecordingPin dc; + SwitchableBusyPin busy; +}; + +/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to +/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing +/// was drawn since, and completing it clears the warning. +TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_update_count(3); + + // Stick the pin after the data has been sent, when the dirty area has already been emptied + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::POWER_ON); + display.busy.busy = true; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_TRUE(display.status_has_warning()); + + display.busy.busy = false; + display.update(); + EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused"; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete"; + EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one"; + EXPECT_FALSE(display.status_has_warning()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + EPaperState state() const { return this->state_; } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI +/// transaction and release both chip selects, or the next command would block. +TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open"; + + display.busy.busy = true; + loop_until_idle(display); + + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "CS left asserted"; + EXPECT_TRUE(display.cs1.level) << "CS1 left asserted"; +} + +/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled. +TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { + TestableT133A01 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream + display.install(&bus); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + + display.busy.busy = true; + loop_until_idle(display); + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + + EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end()) + << "the panel was not powered off (POF) when the update was abandoned"; +} + +} // namespace esphome::epaper_spi::testing From f941fee5edb459fe17f1159800ee6294c4b4d34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:10:14 -1000 Subject: [PATCH 50/66] [homeassistant] Add sensor platform benchmarks (#20637) --- .../bench_homeassistant_sensor.cpp | 196 ++++++++++++++++++ .../components/homeassistant/benchmark.yaml | 3 + .../homeassistant/sensor/__init__.py | 13 ++ 3 files changed, 212 insertions(+) create mode 100644 tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp create mode 100644 tests/benchmarks/components/homeassistant/benchmark.yaml create mode 100644 tests/benchmarks/components/homeassistant/sensor/__init__.py diff --git a/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp new file mode 100644 index 00000000000..8ecf56ed130 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp @@ -0,0 +1,196 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR) + +#include +#include + +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Each benchmark gets its own API server so subscriptions from one do not +// stay in the list scanned by the next. The constructor installs itself as +// global_api_server, which is what HomeassistantSensor::setup() subscribes to. +class ScopedApiServer { + public: + ScopedApiServer() : previous_(api::global_api_server) {} + ~ScopedApiServer() { api::global_api_server = this->previous_; } + api::APIServer *get() { return &this->server_; } + + protected: + api::APIServer *previous_; + api::APIServer server_; +}; + +// The handler only reads the server's subscription list, so the connection is +// never started and its socket never used. +static std::unique_ptr create_connection(api::APIServer *server) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + return std::make_unique(std::make_unique(fd), server); +} + +// State strings as Home Assistant sends them. The handler writes a terminator +// one byte past the state, so the buffers are mutable and the terminator it +// writes lands on the one the literal already has. +static constexpr int kStateCount = 4; +static constexpr size_t kStateBufferSize = 16; +using StateBuffers = char[kStateCount][kStateBufferSize]; +using Messages = std::array; + +static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"}; +static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"}; +static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"}; + +static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) { + Messages msgs; + for (int i = 0; i < kStateCount; i++) { + msgs[i].entity_id = StringRef(entity_id); + if (attribute != nullptr) { + msgs[i].attribute = StringRef(attribute); + } + msgs[i].state = StringRef(states[i]); + } + return msgs; +} + +// --- One imported sensor, numeric state that changes every message --- +// Exercises: subscription match, in-place terminator, parse_number, publish_state. + +static void HomeassistantSensor_StateResponse(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse); + +// --- The same sensor with integer states such as a percentage or a count --- +// Exercises: the same path with no fractional digits to parse. Only the state +// differs from the decimal case, so the gap between the two is the parse. + +static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Integer); + +// --- Attribute import: the subscription and the message both carry an attribute --- +// Exercises: the extra attribute compare before the callback. + +static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("climate.living_room"); + sensor.set_attribute("current_temperature"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Attribute); + +// --- Sixteen imported sensors, message for the last one --- +// Exercises: the linear scan over the subscription list with a string compare +// per entry, which is what a device with many imported entities pays per update. +// The IDs share a length and differ only in the last two bytes, so every +// entry runs a near-full compare, the worst case for the scan. + +static constexpr int kSensorCount = 16; + +static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + + static constexpr const char *const ENTITY_IDS[kSensorCount] = { + "sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03", + "sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07", + "sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11", + "sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15", + }; + HomeassistantSensor sensors[kSensorCount]; + for (int i = 0; i < kSensorCount; i++) { + sensors[i].set_entity_id(ENTITY_IDS[i]); + sensors[i].setup(); + } + const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensors[kSensorCount - 1].state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions); + +// --- Non-numeric state such as "unavailable" --- +// Exercises: the failed parse, the warning the platform logs for it, and the +// NAN publish. The benchmark logger runs at the production default level with +// console output off, so a log line at DEBUG or above costs its formatting +// here, the same as on a device, and a log level change in the platform shows +// up as a delta. + +static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Unavailable); + +} // namespace esphome::homeassistant::benchmarks + +#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR diff --git a/tests/benchmarks/components/homeassistant/benchmark.yaml b/tests/benchmarks/components/homeassistant/benchmark.yaml new file mode 100644 index 00000000000..e3924a22117 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/benchmark.yaml @@ -0,0 +1,3 @@ +# The homeassistant platforms subscribe through the API server. +api: +sensor: diff --git a/tests/benchmarks/components/homeassistant/sensor/__init__.py b/tests/benchmarks/components/homeassistant/sensor/__init__.py new file mode 100644 index 00000000000..c4b3f4d8d67 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/sensor/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # In production the platform's to_code emits this define; the host build + # suppresses that to_code, and the API server needs the define for the + # state subscription path the benchmarks drive. + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + + manifest.to_code = to_code From 42341d05f9a3fda0c2afb410e8b3cc4afa65b713 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 51/66] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f2a78259973..fcdffc0c645 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 0dc1d3b5a19ddd47603dc6938b419c2997acfa66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:25:18 -1000 Subject: [PATCH 52/66] [toshiba] Move the lookup tables to PROGMEM (#20636) --- esphome/components/toshiba/toshiba.cpp | 50 +++++++++----------- tests/components/toshiba/toshiba_tests.cpp | 55 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 406a1bc55db..eae24641c40 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -3,8 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { @@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72; const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E; // RAC-PT1411HWRU Swing const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9; -const std::vector RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; -const std::vector RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; // RAC-PT1411HWRU Fan speeds const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B; constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66}; @@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E; // Hence these look-up tables. In addition, the upper nibble is used here for additional // "negative" and "fractional value" flags as required for some temperatures. // RAC-PT1411HWRU °C Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, - 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, + 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; // RAC-PT1411HWRU °F Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, - 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, - 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = { + 0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05, + 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; // RAS-2819T protocol constants const uint16_t RAS_2819T_HEADER1 = 0xC23D; @@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame // RAS-2819T temperature codes for 18-30°C -static const uint8_t RAS_2819T_TEMP_CODES[] = { +static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = { 0x10, // 18°C 0x30, // 19°C 0x20, // 20°C @@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) { return 0x40; // Default to 24°C } - return RAS_2819T_TEMP_CODES[temp_index]; + return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]); } /** @@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) { // Find the code in the temperature array for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { - if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) { return static_cast(temp_index + 18); // 18°C is the minimum } } @@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { index = static_cast(roundf(temp_adjd)); if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - code = RAC_PT1411HWRU_TEMPERATURE_F[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]); message[9] |= RAC_PT1411HWRU_FLAG_FAH; } else { - code = RAC_PT1411HWRU_TEMPERATURE_C[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]); } if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) { code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY; @@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { // Swing Mode data->reset(); data->space(TOSHIBA_PACKET_SPACE); - switch (this->swing_mode) { - case climate::CLIMATE_SWING_VERTICAL: - this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - break; - - case climate::CLIMATE_SWING_OFF: - default: - this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - } + const uint8_t *swing_frame = + this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF; + uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH]; + progmem_memcpy(swing, swing_frame, sizeof(swing)); + this->encode_(data, swing, sizeof(swing), 1); data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); @@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) { this->target_temperature = static_cast((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9; } } } else { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) { this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; } } @@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Swing mode case RAC_PT1411HWRU_SWING_HEADER: - if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) { + if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; } else { this->swing_mode = climate::CLIMATE_SWING_OFF; diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 528af888544..3c0ce6cd6ce 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); } +// The RAC-PT1411HWRU sends its main message, then a separate swing packet +// wrapped in packet spaces; both use the generic timings. +static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector &bytes) { + ASSERT_GE(actual.size(), 2u); + EXPECT_EQ(actual.front(), -10500); + EXPECT_EQ(actual.back(), -10500); + const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1); + expect_frame(frame, waveform(bytes, GENERIC), 6); +} + +TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) { + Fixture f(MODEL_RAC_PT1411HWRU_C); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}); +} + +// The temperature tables are read on the way out and searched on the way back +// in, so what one fixture sends must decode to the same state in another. +TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { + for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) { + for (int temperature = 16; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature); + Fixture tx(model); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + // The main message is sent before the swing packet + const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; + + Fixture rx(model); + remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); + } + } +} + +// The RAS-2819T table is read for the code on the way out and searched on the way back in. +TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { + for (int temperature = 18; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "temperature " << temperature); + Fixture tx(MODEL_RAS_2819T); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + + Fixture rx(MODEL_RAS_2819T); + remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); + } +} + static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { const remote_base::RawTimings raw = waveform(bytes, t); remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); From 6c24d4aa43c72962619579038a62334126244f6d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:05:16 +0000 Subject: [PATCH 53/66] Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7df6ca4fc7..a54897ef36d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From f4a6d6f18bc3252daf851e199e239666857cfb63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 54/66] [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) --- esphome/platformio/library.py | 7 +++---- esphome/platformio/prefetch.py | 4 ++++ esphome/platformio/runner.py | 11 +++++++++++ tests/unit_tests/test_platformio_prefetch.py | 13 +++++++++++++ tests/unit_tests/test_platformio_runner.py | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e0..769b86519fa 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb993..a86b5a63b97 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d0..cde79af0b68 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6d..8a60e9c3881 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a3..b108cf298ab 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] From 39ea6a32204b8c085e6f7b25c6990c93126b502d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 55/66] [audio] Bump microFLAC to v0.3.0 (#20591) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1480faa8c3f..332638e75bb 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..a490f007d94 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: From c9664d4e4282149fe2daeafefe2ee6840b56d2ad Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 56/66] [wifi] Reject unsupported WPA3 on ESP8266 (#20592) Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com> --- esphome/components/wifi/__init__.py | 7 ++ .../wifi/test_min_auth_mode.py | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/component_tests/wifi/test_min_auth_mode.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f357bf75b35..f500bc6e3f0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -344,6 +344,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -522,6 +528,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 00000000000..e0e5ca98053 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" From b43630cf8e4075d8daf6cdde01f18a471a592c90 Mon Sep 17 00:00:00 2001 From: emes30 <35312919+emes30@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:01:11 +0200 Subject: [PATCH 57/66] [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260) Co-authored-by: J. Nick Koston --- esphome/components/rp2/__init__.py | 2 ++ esphome/components/rp2_pio/__init__.py | 8 ++++---- .../components/rp2040_pio_led_strip/test.rp2350-ard.yaml | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 23346ca6f4b..1ac37f8e456 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -194,6 +194,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b876..96ed422de2d 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -7,9 +7,9 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -20,8 +20,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 00000000000..25135f1d390 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml From bb0546fc0b2633db98f53afd09d5cce81c8caacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 58/66] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 53d504137b9219b1c853beac247712f16a085176 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 59/66] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f8649f968ed..16fddadc878 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1039,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 5a78f636b99f53f01c0bb21cf3ec6b368075b6cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:50 +1300 Subject: [PATCH 60/66] Bump version to 2026.10.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a72069f8449..614b9a6fc05 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b3 +PROJECT_NUMBER = 2026.10.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 15dcce03ff9..3888f8f95d3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b3" +__version__ = "2026.10.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a97ce3a20140c2deddcbc7227926c2775f7ebc48 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 23:58:21 +0200 Subject: [PATCH 61/66] [image] Fix pillow deprecation warning (#20679) --- esphome/components/file/image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) From 6562c49686969f6c23baf5dcdb3cb73a12b6ec01 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:08:13 +0100 Subject: [PATCH 62/66] [thermostat] Allow a dry-only thermostat without cool_action (#20682) --- esphome/components/thermostat/climate.py | 17 +++++---- .../config/cooling_timer_without_action.yaml | 27 ++++++++++++++ .../thermostat/config/dry_only.yaml | 30 ++++++++++++++++ .../thermostat/test_validation.py | 36 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/thermostat/config/cooling_timer_without_action.yaml create mode 100644 tests/component_tests/thermostat/config/dry_only.yaml create mode 100644 tests/component_tests/thermostat/test_validation.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) From a0c7d754cbe037e43499283f32bc30e76de597c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 12:22:35 -1000 Subject: [PATCH 63/66] [syslog] Keep the log level to severity table in flash (#20662) --- esphome/components/syslog/esphome_syslog.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; From e265c0c0fc8e78300f8a9897585d826545dd0820 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:42 -0400 Subject: [PATCH 64/66] [core] Sync the static analysis dependency pins with the code (#20681) --- esphome/components/qr_code/__init__.py | 2 +- esphome/components/runtime_image/__init__.py | 2 +- esphome/idf_component.yml | 2 ++ platformio.ini | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..2ef48602d1b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..96f3d25b6eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY From 5c23a827b65351be1a1692bc9d5c478a245793af Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 00:35:55 +0200 Subject: [PATCH 65/66] [tmc2209] Add current and diagnostic sensors (#20638) --- esphome/components/tmc2209/sensor/__init__.py | 87 +++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.cpp | 95 +++++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.h | 35 +++++++ esphome/components/tmc2209/tmc2209.h | 3 + esphome/components/tmc22xx/tmc22xx.h | 9 ++ tests/components/tmc2209/common.yaml | 23 +++++ 6 files changed, 252 insertions(+) create mode 100644 esphome/components/tmc2209/sensor/__init__.py create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.cpp create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.h diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..19e4813b70d 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -59,7 +61,12 @@ static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +136,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..c39d3fd8ba2 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -84,3 +84,26 @@ esphome: target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result From 7d735f06774c2bd8a7cca2f5cd3a89e2ab64fe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:09 +0200 Subject: [PATCH 66/66] [emontx] Lock the emonTx on reboot when its firmware supports it (#20621) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 18 +- esphome/components/emontx/emontx.cpp | 156 ++++++++++++--- esphome/components/emontx/emontx.h | 29 ++- .../components/emontx/text_sensor/__init__.py | 1 + esphome/core/defines.h | 1 + .../emontx/test_lock_on_reboot.py | 38 ++++ tests/components/emontx/common-version.yaml | 9 + tests/components/emontx/common.h | 21 ++ tests/components/emontx/common.yaml | 7 - tests/components/emontx/emontx_test.cpp | 185 +++++++++++++++++- .../emontx/test-actions.esp8266-ard.yaml | 1 + .../components/emontx/text_sensor/__init__.py | 1 + .../text_sensor/emontx_version_test.cpp | 31 +-- 13 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 tests/component_tests/emontx/test_lock_on_reboot.py create mode 100644 tests/components/emontx/common-version.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), - cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - if config[CONF_LOCK_ON_REBOOT]: - cg.add(var.set_lock_on_reboot(True)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken - /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. - void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; - bool lock_on_reboot_{false}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..8d243043bde 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -69,6 +69,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.set_paused(true); this->sut_.on_shutdown(); EXPECT_TRUE(this->uart_.tx.empty()); } -TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { - this->sut_.set_lock_on_reboot(true); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true);